From 8d41095ac35d179873790ec0b645d2180f14c94c Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 10:47:08 +0300 Subject: [PATCH 01/31] claude md --- CLAUDE.md | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bdcf1e0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,208 @@ +# UTP Service — Project Reference + +Fleet management web service for parking/access control equipment (CAPS system). + +## Stack + +| Layer | Tech | +|---|---| +| Backend | FastAPI + SQLAlchemy 2.0 async (asyncpg), Alembic, asyncssh, psycopg3 | +| Frontend | React 18 + TypeScript, Vite, Ant Design 5, TanStack React Query 5, Zustand | +| DB | PostgreSQL 16 | +| Infra | Docker Compose | + +Run: `docker compose up` — backend :8005, frontend :3005, postgres :5432 + +## Project Layout + +``` +utp_service/ +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI app, routers, lifespan +│ │ ├── config.py # Settings (DATABASE_URL, SECRET_KEY, ENCRYPTION_KEY) +│ │ ├── dependencies.py # get_db, get_current_user +│ │ ├── models/ # SQLAlchemy ORM models +│ │ ├── routers/ # API route handlers +│ │ ├── schemas/ # Pydantic request/response schemas +│ │ ├── services/ # Business logic +│ │ ├── plugins/ # Plugin registry + builtin plugins +│ │ ├── workers/ # Background workers (monitor, startup) +│ │ └── middleware/auth.py # Auth middleware +│ └── alembic/versions/ # Numbered migrations 0001–0009 +└── frontend/ + └── src/ + ├── App.tsx # React Router routes + ├── pages/ # Page-level components + ├── api/ # React Query hooks + API calls + ├── hooks/ # useSSE, useMonitorSSE + ├── components/ # AppLayout, StatusBadge + └── store/auth.ts # Zustand auth store +``` + +## Database Session Pattern + +`autoflush=False` — must call `await db.flush()` explicitly after `db.add()` before any SELECT that needs to see the new rows. `session.begin()` auto-commits on handler return. + +```python +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with AsyncSessionLocal() as session: + async with session.begin(): + yield session +``` + +## Models + +### Object (`objects` table) +Represents a physical site/parking lot. + +Key fields: `name`, `description`, `city`, `client`, `server_ip` (main CAPS server IP), +`db_host/port/name/user/db_pass_enc/db_table/db_via_tunnel` (CAPS PostgreSQL connection), +`ssh_user/ssh_pass_enc/ssh_port` (SSH credentials, shared across site), +`is_active`, `tags` (M2M → Tag) + +### Device (`devices` table) +Network device at a site. + +Key fields: `object_id` (FK), `ip` (unique per object: `uq_device_object_ip`), +`hostname`, `mac`, `category`, `role`, `location`, `vendor`, `model`, +`source` (`manual`/`db_sync`/`auto_ssh`/`csv`/`sheets`), +`status` (`unknown`/`online`/`offline`/`degraded`), `monitor_enabled`, +`rack_id` (FK → Rack), `connection_params` (JSONB), +`device_meta` (JSONB — e.g. `{"plugin": "camera_1", "cls": "..."}`), +`capabilities` (ARRAY), `ssh_user_override/ssh_pass_override_enc/ssh_port_override` + +Device categories: `main_server`, `vm`, `router`, `embedded`, `camera`, `io_board`, `bank_terminal`, `cash_register`, `other` +Device roles: `plate`, `face`, `overview`, `other` +Device locations: `server_room`, `rack`, `street` + +### Rack (`racks` table) +`id`, `object_id`, `name`, `description`, `location`, `zone_id` (FK → Zone) + +### Zone (`zones` table) +`id`, `object_id`, `name`, `description`, racks relationship + +### Task (`tasks` table) +`id`, `type`, `status` (pending/running/success/partial/failed/stale/cancelled), +`target_scope` (JSONB — see Task System), `params` (JSONB), `result` (JSONB), +`created_by`, timestamps, → TaskEvent, TaskResult relationships + +### Alert (`alerts` table) +`id`, `device_id`, `status` (open/acknowledged/resolved), `message`, timestamps + +### ConfigSnapshot / SnapshotContent +Content-addressed: `SnapshotContent(sha256 PK, content)`, `ConfigSnapshot(device_id, sha256 FK, path, captured_at)` + +### Tag (`tags` table) +`id`, `name`, `color`, `tag_type`; M2M with objects via `object_tags` + +## Routers (all under `/api/v1/`) + +| Router file | Prefix | Key endpoints | +|---|---|---| +| auth.py | /auth | POST login, POST refresh, POST logout | +| objects.py | /objects | CRUD + GET /meta (cities/clients) + POST /{id}/discover + POST /{id}/sync | +| devices.py | /objects/{id}/devices | CRUD + POST /import + POST /import/preview | +| global_devices.py | /devices | GET all devices across objects (filters: category, city, client, object_id, status) | +| racks.py | /objects/{id}/racks | CRUD | +| zones.py | /objects/{id}/zones | CRUD | +| tasks.py | /tasks | POST create + GET list/detail + GET /{id}/events (SSE) | +| events.py | /events | GET SSE stream (task_id param) | +| alerts.py | /alerts | GET list + POST /{id}/acknowledge + POST /{id}/resolve | +| snapshots.py | /snapshots | GET list + GET /{id}/diff | +| tags.py | /tags | CRUD | +| admin.py | /admin | User mgmt + plugin control + health | + +## Services + +| File | Purpose | +|---|---| +| device_discovery.py | SSH-based discovery: SSH → rack PC → parse /etc/caps/config.ini → extract plugin IPs → upsert devices | +| object_db.py | Sync devices from CAPS PostgreSQL DB | +| task.py | Task dispatch, per-device async execution (concurrency: 20) | +| ssh.py | asyncssh client with concurrency limits | +| crypto.py | Fernet encrypt/decrypt for passwords (db_pass_enc, ssh_pass_enc) | +| sse.py | SSEManager pub/sub for Server-Sent Events | +| camera.py | Hikvision ISAPI HTTP client | +| snapshot.py | SHA-256 content-addressed config snapshot storage | +| sheets.py | Google Sheets device import (gspread) | +| auth.py | JWT + bcrypt, access (15min) + refresh (7d) tokens | + +## SSH Discovery (`device_discovery.py`) + +Flow: +1. Resolve CAPS DB connection: use explicit `obj.db_*` fields OR autodetect by SSHing into `obj.server_ip` → read `/etc/caps/config.ini` → extract `db_uri` +2. Query racks table → get `(external_id, rack_host_ip, rack_name)` tuples +3. For each rack: SSH into `rack_host_ip` → read `/etc/caps/config.ini` +4. Parse config with `RawConfigParser(strict=False)` (strict=False needed for duplicate keys like `ini=` in uwsgi) +5. Extract devices from `[plugin:*]` sections by parsing URI fields (tcp://, http://) — skip serial://, localhost +6. Upsert: SELECT by `(object_id, ip)`, INSERT or UPDATE category/role/hostname +7. `await db.flush()` after each rack (autoflush=False — without flush, SELECT won't see pending db.add() rows) + +Hostname format: `rack{rack_name}-{plugin_name}` e.g. `rack01-camera_grz` + +## Task System + +`target_scope` JSONB shapes: +- `{"type": "device", "id": 123}` +- `{"type": "object", "id": 4}` +- `{"type": "category", "object_id": 4, "category": "main_server"}` +- `{"type": "device_list", "ids": [1, 2, 3]}` + +Task events streamed via SSE at `GET /api/v1/events?task_id={id}` +Monitor events at `GET /api/v1/events?task_id=__monitor__` + +SSE fix: `onerror` must `throw err` to prevent fetchEventSource auto-retry loop on 401. + +## Frontend Routes + +``` +/ → redirect to /inventory/objects +/inventory/objects → ObjectsPage (mode=inventory) — city-grouped list +/inventory/objects/:id → InventoryObjectDetailPage — device CRUD, SSH discovery +/inventory/objects/:id/snapshots → SnapshotsPage +/work/objects → ObjectsPage (mode=work) — city-grouped list +/work/objects/:id → WorkObjectDetailPage — task execution, device status +/work/devices → WorkDevicesPage — global device list with filters +``` + +Layout nav: ИНВЕНТАРЬ (DatabaseOutlined) | РАБОТА (ControlOutlined, AppstoreOutlined) + +## Frontend API Hooks (`src/api/`) + +- `objects.ts`: useObjects, useObject, useCreateObject, useUpdateObject, useDeleteObject, useObjectsMeta (cities/clients autocomplete), useDiscoverObject, useSyncFromDB, useSyncFromSheets +- `devices.ts`: useDevices, useDevice, useCreateDevice, useUpdateDevice, useDeleteDevice, useImportDevicesPreview, useImportDevices, useAllDevices (global) +- `tasks.ts`: useTasks, useTaskDetail, useCreateTask, useTaskSSE +- `alerts.ts`: useAlerts, useAcknowledgeAlert, useResolveAlert +- `snapshots.ts`: useSnapshots, useSnapshotDetail, useSnapshotDiff, useCaptureSnapshot + +## Plugins (builtin) + +Located in `backend/app/plugins/builtin/`: +- `camera/`: get_status (Hikvision ISAPI), set_ntp +- `mikrotik/`: get_config, get_ntp, set_ntp (RouterOS via SSH) +- `ssh/`: debian_system_info, get_file, get_timedatectl, ssh_command +- `common/`: ping + +## Migrations + +Apply via: `docker compose exec backend alembic upgrade head` + +Files in `backend/alembic/versions/`: +- 0001: initial schema +- 0002: tasks +- 0003: racks +- 0004: zones +- 0005: snapshots +- 0006: alerts +- 0007: plugins +- 0008: inventory (vendor, location, connection_params on devices) +- 0009: city, client on objects + +## Known Gotchas + +- `autoflush=False`: always `await db.flush()` after `db.add()` before subsequent SELECTs in the same transaction +- `await db.refresh(obj)` — no attribute_names arg, otherwise server-generated columns (updated_at) aren't refreshed +- `RawConfigParser(strict=False)` — CAPS configs have duplicate keys in [uwsgi] section +- SSH discovery: `onerror(err) { throw err }` in fetchEventSource to stop auto-retry on non-200 +- Encrypted fields stored as Fernet ciphertext; use `crypto.encrypt()` / `crypto.decrypt()` From cdee38d433714d5e73cad7f7fcf462835df78a77 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 11:02:12 +0300 Subject: [PATCH 02/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/models/device.py | 2 +- backend/app/services/device_discovery.py | 156 +++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) 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/services/device_discovery.py b/backend/app/services/device_discovery.py index e784c91..854eff5 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -155,6 +155,73 @@ async def _read_remote_config( return result.stdout +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: + print(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: + print(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: @@ -400,7 +467,9 @@ async def discover_via_ssh( result.errors.append(f"SSH to {rack_host} failed: {exc}") continue + print(f"[ssh_discovery] Processing rack {rack_host} ({rack_name})") devices_found = _parse_config(config_text, rack_host) + print(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" @@ -427,6 +496,9 @@ async def discover_via_ssh( if existing.hostname != hostname: existing.hostname = hostname changed = True + + action = "UPDATED" if changed else "SKIPPED" + print(f"[ssh_discovery] Device {found.ip} ({hostname}) - {action}") result.updated += 1 if changed else 0 result.skipped += 0 if changed else 1 else: @@ -439,8 +511,10 @@ async def discover_via_ssh( source="auto_ssh", device_meta={"plugin": found.plugin_name, "cls": found.cls}, )) + print(f"[ssh_discovery] Device {found.ip} ({hostname}) - ADDED") result.created += 1 except Exception as exc: + print(f"[ssh_discovery] Error upserting {found.ip}: {exc}") result.errors.append(f"Error upserting {found.ip}: {exc}") # Flush after each rack so that subsequent SELECTs (for other racks @@ -452,4 +526,86 @@ async def discover_via_ssh( except Exception as exc: result.errors.append(f"Flush error after rack {rack_host}: {exc}") + # Step 3: Discover MikroTik via SSH_CLIENT + print(f"[ssh_discovery] 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: + print(f"[ssh_discovery] Discovered MikroTik at {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: + print(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - SKIPPED") + result.skipped += 1 + else: + db.add(Device( + object_id=obj.id, + ip=mikrotik_ip, + hostname="mikrotik", + category="router", + role="other", + source="auto_ssh", + )) + print(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - ADDED") + result.created += 1 + except Exception as exc: + print(f"[ssh_discovery] Error upserting {mikrotik_ip}: {exc}") + result.errors.append(f"Error upserting {mikrotik_ip}: {exc}") + + await db.flush() + else: + print(f"[ssh_discovery] No MikroTik found via SSH_CLIENT") + + # Step 4: Discover Proxmox via port 8006 scan + print(f"[ssh_discovery] 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: + print(f"[ssh_discovery] Discovered {len(proxmox_ips)} Proxmox server(s): {proxmox_ips}") + 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: + print(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - SKIPPED") + result.skipped += 1 + else: + db.add(Device( + object_id=obj.id, + ip=proxmox_ip, + hostname=hostname, + category="vm", + role="other", + source="auto_ssh", + )) + print(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - ADDED") + result.created += 1 + except Exception as exc: + print(f"[ssh_discovery] Error upserting {proxmox_ip}: {exc}") + result.errors.append(f"Error upserting {proxmox_ip}: {exc}") + + await db.flush() + else: + print(f"[ssh_discovery] No Proxmox servers found via port 8006 scan") + return result From 47b0928b2a81c2635af31b98f65807a5ce98c634 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 11:46:32 +0300 Subject: [PATCH 03/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 57 +++++++++++++----------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 854eff5..6307bd5 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -11,6 +11,7 @@ Limitations (devices not discoverable automatically): - Physical MikroTik router (not in rack configs) """ +import logging import re from configparser import RawConfigParser from dataclasses import dataclass, field @@ -26,6 +27,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"} @@ -183,7 +186,7 @@ async def _discover_mikrotik_via_ssh_client( if parts: return parts[0] except Exception as e: - print(f"[ssh_discovery] Failed to discover MikroTik via SSH_CLIENT: {e}") + logger.warning(f"[ssh_discovery] Failed to discover MikroTik via SSH_CLIENT: {e}") return None @@ -218,7 +221,7 @@ async def _discover_proxmox_via_port_scan( ips = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()] return ips except Exception as e: - print(f"[ssh_discovery] Failed to discover Proxmox servers via port scan: {e}") + logger.warning(f"[ssh_discovery] Failed to discover Proxmox servers via port scan: {e}") return [] @@ -467,9 +470,9 @@ async def discover_via_ssh( result.errors.append(f"SSH to {rack_host} failed: {exc}") continue - print(f"[ssh_discovery] Processing rack {rack_host} ({rack_name})") + logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_name})") devices_found = _parse_config(config_text, rack_host) - print(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}") + 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" @@ -484,23 +487,25 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing is not None: - changed = False + changes = [] if existing.category != found.category and existing.source == "auto_ssh": existing.category = found.category - changed = True + changes.append(f"category: {existing.category}→{found.category}") if existing.role == "other" and found.role != "other": existing.role = found.role - changed = True + changes.append(f"role: other→{found.role}") # 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 + changes.append(f"hostname: {existing.hostname}→{hostname}") - action = "UPDATED" if changed else "SKIPPED" - print(f"[ssh_discovery] Device {found.ip} ({hostname}) - {action}") - result.updated += 1 if changed else 0 - result.skipped += 0 if changed else 1 + if changes: + logger.info(f"[ssh_discovery] Device {found.ip} ({hostname}) - UPDATED ({', '.join(changes)})") + result.updated += 1 + else: + logger.debug(f"[ssh_discovery] Device {found.ip} ({hostname}) - SKIPPED (no changes, category={existing.category}, role={existing.role}, hostname={existing.hostname})") + result.skipped += 1 else: db.add(Device( object_id=obj.id, @@ -511,10 +516,10 @@ async def discover_via_ssh( source="auto_ssh", device_meta={"plugin": found.plugin_name, "cls": found.cls}, )) - print(f"[ssh_discovery] Device {found.ip} ({hostname}) - ADDED") + logger.info(f"[ssh_discovery] Device {found.ip} ({hostname}) - ADDED (category={found.category}, role={found.role})") result.created += 1 except Exception as exc: - print(f"[ssh_discovery] Error upserting {found.ip}: {exc}") + logger.error(f"[ssh_discovery] Error upserting {found.ip}: {exc}") result.errors.append(f"Error upserting {found.ip}: {exc}") # Flush after each rack so that subsequent SELECTs (for other racks @@ -527,7 +532,7 @@ async def discover_via_ssh( result.errors.append(f"Flush error after rack {rack_host}: {exc}") # Step 3: Discover MikroTik via SSH_CLIENT - print(f"[ssh_discovery] Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}") + logger.info(f"[ssh_discovery] 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, @@ -535,7 +540,7 @@ async def discover_via_ssh( password=ssh_password, ) if mikrotik_ip: - print(f"[ssh_discovery] Discovered MikroTik at {mikrotik_ip}") + logger.info(f"[ssh_discovery] Discovered MikroTik at {mikrotik_ip}") try: existing = (await db.execute( select(Device).where( @@ -545,7 +550,7 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing is not None: - print(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - SKIPPED") + logger.debug(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - SKIPPED (already exists, category={existing.category}, hostname={existing.hostname})") result.skipped += 1 else: db.add(Device( @@ -556,18 +561,18 @@ async def discover_via_ssh( role="other", source="auto_ssh", )) - print(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - ADDED") + logger.info(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - ADDED (category=router, role=other)") result.created += 1 except Exception as exc: - print(f"[ssh_discovery] Error upserting {mikrotik_ip}: {exc}") + logger.error(f"[ssh_discovery] Error upserting {mikrotik_ip}: {exc}") result.errors.append(f"Error upserting {mikrotik_ip}: {exc}") await db.flush() else: - print(f"[ssh_discovery] No MikroTik found via SSH_CLIENT") + logger.debug(f"[ssh_discovery] No MikroTik found via SSH_CLIENT") # Step 4: Discover Proxmox via port 8006 scan - print(f"[ssh_discovery] Attempting to discover Proxmox servers via port 8006 scan on {obj.server_ip or 'main_server'}") + logger.info(f"[ssh_discovery] 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, @@ -575,7 +580,7 @@ async def discover_via_ssh( password=ssh_password, ) if proxmox_ips: - print(f"[ssh_discovery] Discovered {len(proxmox_ips)} Proxmox server(s): {proxmox_ips}") + logger.info(f"[ssh_discovery] Discovered {len(proxmox_ips)} Proxmox server(s): {proxmox_ips}") for idx, proxmox_ip in enumerate(proxmox_ips, 1): hostname = f"proxmox_{idx}" try: @@ -587,7 +592,7 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing is not None: - print(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - SKIPPED") + logger.debug(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - SKIPPED (already exists, category={existing.category}, hostname={existing.hostname})") result.skipped += 1 else: db.add(Device( @@ -598,14 +603,14 @@ async def discover_via_ssh( role="other", source="auto_ssh", )) - print(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - ADDED") + logger.info(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - ADDED (category=vm, role=other)") result.created += 1 except Exception as exc: - print(f"[ssh_discovery] Error upserting {proxmox_ip}: {exc}") + logger.error(f"[ssh_discovery] Error upserting {proxmox_ip}: {exc}") result.errors.append(f"Error upserting {proxmox_ip}: {exc}") await db.flush() else: - print(f"[ssh_discovery] No Proxmox servers found via port 8006 scan") + logger.debug(f"[ssh_discovery] No Proxmox servers found via port 8006 scan") return result From e71e068cf0e3e64daf5cfe734f86cd540a44388c Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 11:52:42 +0300 Subject: [PATCH 04/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 7 +++++++ 1 file changed, 7 insertions(+) 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): From 3adced13a0481264a069d188f01fc4edaafcf29b Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 11:59:03 +0300 Subject: [PATCH 05/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 51 ++++++++++++++---------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 6307bd5..7887e84 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -110,10 +110,12 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: plugin_name = section[len("plugin:"):] if any(plugin_name.startswith(prefix) for prefix in _SKIP_PLUGIN_PREFIXES): + logger.debug(f"[ssh_discovery] Plugin {plugin_name} - IGNORED (prefix in skip list: {_SKIP_PLUGIN_PREFIXES})") continue cls = parser.get(section, "cls", fallback="") if not cls: + logger.debug(f"[ssh_discovery] Plugin {plugin_name} - IGNORED (no 'cls' field)") continue # Collect all URI-like values in this section @@ -123,9 +125,18 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: if not raw: continue ip = _extract_ip(raw) - if not ip or ip == rack_host or ip in seen_ips: + if not ip: + logger.debug(f"[ssh_discovery] Plugin {plugin_name} field {field_name}='{raw}' - IGNORED (not a valid network IP)") continue + if ip == rack_host: + logger.debug(f"[ssh_discovery] IP {ip} from plugin {plugin_name} - IGNORED (matches rack_host)") + continue + if ip in seen_ips: + logger.debug(f"[ssh_discovery] IP {ip} from plugin {plugin_name} - IGNORED (duplicate, already seen)") + continue + seen_ips.add(ip) + logger.debug(f"[ssh_discovery] IP {ip} from plugin {plugin_name} - EXTRACTED (cls={cls}, category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)})") discovered.append(DiscoveredDevice( ip=ip, category=_cls_to_category(cls), @@ -489,22 +500,22 @@ async def discover_via_ssh( if existing is not None: changes = [] if existing.category != found.category and existing.source == "auto_ssh": - existing.category = found.category changes.append(f"category: {existing.category}→{found.category}") + existing.category = found.category if existing.role == "other" and found.role != "other": - existing.role = found.role changes.append(f"role: other→{found.role}") + existing.role = found.role # Update hostname if it was never set or still auto-generated if not existing.hostname or existing.hostname.startswith("rack"): if existing.hostname != hostname: + changes.append(f"hostname: '{existing.hostname}'→'{hostname}'") existing.hostname = hostname - changes.append(f"hostname: {existing.hostname}→{hostname}") if changes: - logger.info(f"[ssh_discovery] Device {found.ip} ({hostname}) - UPDATED ({', '.join(changes)})") + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - UPDATED: {', '.join(changes)}") result.updated += 1 else: - logger.debug(f"[ssh_discovery] Device {found.ip} ({hostname}) - SKIPPED (no changes, category={existing.category}, role={existing.role}, hostname={existing.hostname})") + 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 else: db.add(Device( @@ -516,10 +527,10 @@ async def discover_via_ssh( source="auto_ssh", device_meta={"plugin": found.plugin_name, "cls": found.cls}, )) - logger.info(f"[ssh_discovery] Device {found.ip} ({hostname}) - ADDED (category={found.category}, role={found.role})") + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ADDED: category={found.category}, role={found.role}, hostname={hostname}, source=auto_ssh") result.created += 1 except Exception as exc: - logger.error(f"[ssh_discovery] Error upserting {found.ip}: {exc}") + logger.error(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ERROR: {exc}") result.errors.append(f"Error upserting {found.ip}: {exc}") # Flush after each rack so that subsequent SELECTs (for other racks @@ -532,7 +543,7 @@ async def discover_via_ssh( result.errors.append(f"Flush error after rack {rack_host}: {exc}") # Step 3: Discover MikroTik via SSH_CLIENT - logger.info(f"[ssh_discovery] Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}") + 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, @@ -540,7 +551,7 @@ async def discover_via_ssh( password=ssh_password, ) if mikrotik_ip: - logger.info(f"[ssh_discovery] Discovered MikroTik at {mikrotik_ip}") + logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}") try: existing = (await db.execute( select(Device).where( @@ -550,7 +561,7 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing is not None: - logger.debug(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - SKIPPED (already exists, category={existing.category}, hostname={existing.hostname})") + logger.info(f"[ssh_discovery] {mikrotik_ip} (mikrotik) - SKIPPED: already exists (source={existing.source}, category={existing.category}, hostname={existing.hostname})") result.skipped += 1 else: db.add(Device( @@ -561,18 +572,18 @@ async def discover_via_ssh( role="other", source="auto_ssh", )) - logger.info(f"[ssh_discovery] Device {mikrotik_ip} (mikrotik) - ADDED (category=router, role=other)") + logger.info(f"[ssh_discovery] {mikrotik_ip} (mikrotik) - ADDED: category=router, role=other, hostname=mikrotik, source=auto_ssh") result.created += 1 except Exception as exc: - logger.error(f"[ssh_discovery] Error upserting {mikrotik_ip}: {exc}") + logger.error(f"[ssh_discovery] {mikrotik_ip} (mikrotik) - ERROR: {exc}") result.errors.append(f"Error upserting {mikrotik_ip}: {exc}") await db.flush() else: - logger.debug(f"[ssh_discovery] No MikroTik found via SSH_CLIENT") + 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 - logger.info(f"[ssh_discovery] Attempting to discover Proxmox servers via port 8006 scan on {obj.server_ip or 'main_server'}") + 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, @@ -580,7 +591,7 @@ async def discover_via_ssh( password=ssh_password, ) if proxmox_ips: - logger.info(f"[ssh_discovery] Discovered {len(proxmox_ips)} Proxmox server(s): {proxmox_ips}") + logger.info(f"[ssh_discovery] Proxmox discovery found {len(proxmox_ips)} server(s) on port 8006: {proxmox_ips}") for idx, proxmox_ip in enumerate(proxmox_ips, 1): hostname = f"proxmox_{idx}" try: @@ -592,7 +603,7 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing is not None: - logger.debug(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - SKIPPED (already exists, category={existing.category}, hostname={existing.hostname})") + logger.info(f"[ssh_discovery] {proxmox_ip} ({hostname}) - SKIPPED: already exists (source={existing.source}, category={existing.category}, hostname={existing.hostname})") result.skipped += 1 else: db.add(Device( @@ -603,14 +614,14 @@ async def discover_via_ssh( role="other", source="auto_ssh", )) - logger.info(f"[ssh_discovery] Device {proxmox_ip} ({hostname}) - ADDED (category=vm, role=other)") + logger.info(f"[ssh_discovery] {proxmox_ip} ({hostname}) - ADDED: category=vm, role=other, hostname={hostname}, source=auto_ssh") result.created += 1 except Exception as exc: - logger.error(f"[ssh_discovery] Error upserting {proxmox_ip}: {exc}") + logger.error(f"[ssh_discovery] {proxmox_ip} ({hostname}) - ERROR: {exc}") result.errors.append(f"Error upserting {proxmox_ip}: {exc}") await db.flush() else: - logger.debug(f"[ssh_discovery] No Proxmox servers found via port 8006 scan") + logger.info(f"[ssh_discovery] Proxmox: No servers found via port 8006 scan (reason: no hosts responding on port 8006)") return result From 475250eb6f84ecee249d775bf99713ffa3d635c2 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:02:25 +0300 Subject: [PATCH 06/31] =?UTF-8?q?haiku=20=D0=92=D0=B0=D0=B9=D0=B1=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 125 +++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 7887e84..51b80c2 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -339,12 +339,24 @@ async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str, str]]: # ── 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) async def discover_via_ssh( @@ -514,9 +526,25 @@ async def discover_via_ssh( 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=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=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, @@ -529,9 +557,25 @@ async def discover_via_ssh( )) logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ADDED: category={found.category}, role={found.role}, hostname={hostname}, 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, + )) # Flush after each rack so that subsequent SELECTs (for other racks # that may share the same device IPs) see the just-added rows. @@ -563,6 +607,14 @@ async def discover_via_ssh( 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, @@ -574,9 +626,25 @@ async def discover_via_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: @@ -605,6 +673,14 @@ async def discover_via_ssh( 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, @@ -616,12 +692,61 @@ async def discover_via_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("=" * 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"] + + if added: + logger.info("[ssh_discovery] ADDED devices:") + for action in added: + logger.info(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + + if updated: + logger.info("[ssh_discovery] UPDATED devices:") + for action in updated: + logger.info(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + + if skipped: + logger.info("[ssh_discovery] SKIPPED devices:") + for action in skipped: + logger.info(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + + if errors: + logger.error("[ssh_discovery] ERROR devices:") + for action in errors: + logger.error(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + + logger.info("=" * 100) + return result From 5b6324e9672051185d2be7e391e18759ca9de63e Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:11:39 +0300 Subject: [PATCH 07/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 51b80c2..16f4b77 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -103,40 +103,49 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: 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.debug(f"[ssh_discovery] Plugin {plugin_name} - IGNORED (prefix in skip list: {_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.debug(f"[ssh_discovery] Plugin {plugin_name} - IGNORED (no 'cls' field)") + logger.info(f"[ssh_discovery] Plugin '{plugin_name}' - IGNORED (no 'cls' field)") continue + logger.info(f"[ssh_discovery] Plugin '{plugin_name}' (cls={cls})") + # Collect all URI-like values in this section uri_fields = ("uri", "frame_uri", "host") + found_ip_in_plugin = False for field_name in uri_fields: raw = parser.get(section, field_name, fallback=None) if not raw: + logger.debug(f"[ssh_discovery] {field_name}: not set") continue + logger.debug(f"[ssh_discovery] {field_name}='{raw}'") ip = _extract_ip(raw) if not ip: - logger.debug(f"[ssh_discovery] Plugin {plugin_name} field {field_name}='{raw}' - IGNORED (not a valid network IP)") + logger.info(f"[ssh_discovery] {field_name}='{raw}' - IGNORED (not a valid network IP)") continue if ip == rack_host: - logger.debug(f"[ssh_discovery] IP {ip} from plugin {plugin_name} - IGNORED (matches rack_host)") + logger.info(f"[ssh_discovery] IP {ip} - IGNORED (matches rack_host {rack_host})") continue if ip in seen_ips: - logger.debug(f"[ssh_discovery] IP {ip} from plugin {plugin_name} - IGNORED (duplicate, already seen)") + logger.info(f"[ssh_discovery] IP {ip} - IGNORED (duplicate, already seen)") continue seen_ips.add(ip) - logger.debug(f"[ssh_discovery] IP {ip} from plugin {plugin_name} - EXTRACTED (cls={cls}, category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)})") + logger.info(f"[ssh_discovery] IP {ip} - EXTRACTED (category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)})") discovered.append(DiscoveredDevice( ip=ip, category=_cls_to_category(cls), @@ -144,6 +153,7 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: plugin_name=plugin_name, cls=cls, )) + found_ip_in_plugin = True return discovered From 8ad41d4a1018d6ecdaf1eefc2a6e2acc008fcdcb Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:14:40 +0300 Subject: [PATCH 08/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=207?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 16f4b77..b12f003 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -130,22 +130,22 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: for field_name in uri_fields: raw = parser.get(section, field_name, fallback=None) if not raw: - logger.debug(f"[ssh_discovery] {field_name}: not set") + logger.info(f"[ssh_discovery] {field_name}: (not set)") continue - logger.debug(f"[ssh_discovery] {field_name}='{raw}'") + logger.info(f"[ssh_discovery] {field_name}='{raw}'") ip = _extract_ip(raw) if not ip: - logger.info(f"[ssh_discovery] {field_name}='{raw}' - IGNORED (not a valid network IP)") + logger.info(f"[ssh_discovery] → IGNORED (not a valid network IP)") continue if ip == rack_host: - logger.info(f"[ssh_discovery] IP {ip} - IGNORED (matches rack_host {rack_host})") + logger.info(f"[ssh_discovery] → IGNORED (matches rack_host {rack_host})") continue if ip in seen_ips: - logger.info(f"[ssh_discovery] IP {ip} - IGNORED (duplicate, already seen)") + logger.info(f"[ssh_discovery] → IGNORED (duplicate, already seen)") continue seen_ips.add(ip) - logger.info(f"[ssh_discovery] IP {ip} - EXTRACTED (category={_cls_to_category(cls)}, role={_cls_to_role(cls, plugin_name)})") + 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), @@ -155,6 +155,9 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: )) found_ip_in_plugin = True + if not found_ip_in_plugin: + logger.info(f"[ssh_discovery] → No network devices found in this plugin") + return discovered From c8d2538015858eee2f3e8375219bad7eadb7fab6 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:26:54 +0300 Subject: [PATCH 09/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=208?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 70 +++++++++++++++++++++--- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index b12f003..6e8c406 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -506,7 +506,58 @@ async def discover_via_ssh( result.errors.append(f"SSH to {rack_host} failed: {exc}") continue + # Add the rack itself as a main_server device if it doesn't exist logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_name})") + try: + existing_rack = (await db.execute( + select(Device).where( + Device.object_id == obj.id, + Device.ip == rack_host, + ) + )).scalar_one_or_none() + + if existing_rack is None: + rack_hostname = f"rack{rack_name}" + db.add(Device( + object_id=obj.id, + ip=rack_host, + hostname=rack_hostname, + category="embedded", + role="other", + source="auto_ssh", + )) + logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: category=main_server, role=other, hostname={rack_hostname}, source=auto_ssh") + result.created += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=rack_hostname, + action="ADDED", + reason="rack main server discovered via SSH connection", + category="embedded", + role="other", + )) + else: + logger.info(f"[ssh_discovery] {rack_host} ({existing_rack.hostname}) - SKIPPED: rack already exists in devices") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=existing_rack.hostname, + action="SKIPPED", + reason="rack already exists in devices", + category=existing_rack.category, + role=existing_rack.role, + )) + except Exception as exc: + logger.error(f"[ssh_discovery] {rack_host} - ERROR adding rack: {exc}") + result.errors.append(f"Error adding rack {rack_host}: {exc}") + result.actions.append(DeviceAction( + ip=rack_host, + hostname=f"rack{rack_name}", + action="ERROR", + reason=str(exc), + category="embedded", + role="other", + )) devices_found = _parse_config(config_text, rack_host) logger.info(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}") @@ -732,6 +783,7 @@ async def discover_via_ssh( # ── 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 @@ -740,25 +792,27 @@ async def discover_via_ssh( 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:") + logger.info("[ssh_discovery] ─── ADDED devices ───") for action in added: - logger.info(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + 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:") + logger.info("[ssh_discovery] ─── UPDATED devices ───") for action in updated: - logger.info(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + 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:") + logger.info("[ssh_discovery] ─── SKIPPED devices ───") for action in skipped: - logger.info(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + 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:") + logger.error("[ssh_discovery] ─── ERROR devices ───") for action in errors: - logger.error(f" {action.ip:20} ({action.hostname:30}) | category={action.category:15} role={action.role:10} | Reason: {action.reason}") + logger.error(f" {action.ip:20} ({action.hostname:30}) | {action.category:15} | {action.role:10} | {action.reason}") logger.info("=" * 100) From 0659f60fa320001d213e196687ee5d1355bca266 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:32:06 +0300 Subject: [PATCH 10/31] =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B1=20haiku=209?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 6e8c406..f496fc9 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -507,7 +507,9 @@ async def discover_via_ssh( continue # Add the rack itself as a main_server device if it doesn't exist - logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_name})") + # Use external_id (e.g., "lane-1-1") as the hostname + rack_hostname = external_id + logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_hostname})") try: existing_rack = (await db.execute( select(Device).where( @@ -517,7 +519,6 @@ async def discover_via_ssh( )).scalar_one_or_none() if existing_rack is None: - rack_hostname = f"rack{rack_name}" db.add(Device( object_id=obj.id, ip=rack_host, @@ -552,7 +553,7 @@ async def discover_via_ssh( result.errors.append(f"Error adding rack {rack_host}: {exc}") result.actions.append(DeviceAction( ip=rack_host, - hostname=f"rack{rack_name}", + hostname=rack_hostname, action="ERROR", reason=str(exc), category="embedded", From 435e425cd140c19a1b3fb1b07b956d52a3e28e2f Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:38:42 +0300 Subject: [PATCH 11/31] =?UTF-8?q?haiku=20=D0=B2=D0=B0=D0=B9=D0=B1=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index f496fc9..58a51f6 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -479,6 +479,14 @@ async def discover_via_ssh( return result # Step 2: SSH into each rack, parse config, upsert devices + # Build mapping of external_id -> Rack ID for device assignment + rack_id_mapping = {} + from app.models.rack import Rack + all_racks = (await db.execute(select(Rack).where(Rack.object_id == obj.id))).scalars().all() + for rack in all_racks: + if rack.external_id: + rack_id_mapping[rack.external_id] = rack.id + for external_id, rack_host, rack_name in rack_hosts: # Resolve config_path override from the embedded device's connection_params existing_rack_pc = (await db.execute( @@ -563,8 +571,9 @@ async def discover_via_ssh( 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}" + # Hostname: "{external_id}-{plugin_name}", e.g. "lane-1-1-camera_1" + hostname = f"{external_id}-{found.plugin_name}" + rack_id = rack_id_mapping.get(external_id) try: existing = (await db.execute( @@ -583,10 +592,14 @@ async def discover_via_ssh( changes.append(f"role: other→{found.role}") existing.role = found.role # Update hostname if it was never set or still auto-generated - if not existing.hostname or existing.hostname.startswith("rack"): + if not existing.hostname or existing.hostname.startswith("rack") or existing.hostname.startswith("lane"): 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}") if changes: logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - UPDATED: {', '.join(changes)}") @@ -618,9 +631,10 @@ async def discover_via_ssh( category=found.category, role=found.role, source="auto_ssh", + rack_id=rack_id, 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}, source=auto_ssh") + logger.info(f"[ssh_discovery] {found.ip} ({found.plugin_name}) - ADDED: category={found.category}, role={found.role}, hostname={hostname}, rack_id={rack_id}, source=auto_ssh") result.created += 1 result.actions.append(DeviceAction( ip=found.ip, From 23ee10a28e477aaeb2d8fe200843a7bc346620a6 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:42:05 +0300 Subject: [PATCH 12/31] =?UTF-8?q?haiku=20=D0=92=D0=B0=D0=B9=D0=B1=2011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 39 ++++++++++++++++++++---- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 58a51f6..1c760c6 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -96,8 +96,17 @@ 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) @@ -137,8 +146,8 @@ def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: if not ip: logger.info(f"[ssh_discovery] → IGNORED (not a valid network IP)") continue - if ip == rack_host: - logger.info(f"[ssh_discovery] → IGNORED (matches rack_host {rack_host})") + 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)") @@ -567,7 +576,9 @@ async def discover_via_ssh( category="embedded", role="other", )) - devices_found = _parse_config(config_text, rack_host) + # Get all rack IPs to exclude them from device discovery + all_rack_ips = [host for _, host, _ in rack_hosts] + 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: @@ -585,6 +596,22 @@ async def discover_via_ssh( if existing is not None: 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 @@ -592,7 +619,7 @@ async def discover_via_ssh( changes.append(f"role: other→{found.role}") existing.role = found.role # Update hostname if it was never set or still auto-generated - if not existing.hostname or existing.hostname.startswith("rack") or existing.hostname.startswith("lane"): + if not existing.hostname or existing.hostname.startswith("rack") or (existing.hostname.startswith("lane") and not existing.hostname.startswith(f"{external_id}")): if existing.hostname != hostname: changes.append(f"hostname: '{existing.hostname}'→'{hostname}'") existing.hostname = hostname From 6c081d5580b77f3c355b744f3424cc1193b02512 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:45:04 +0300 Subject: [PATCH 13/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=BD=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B1=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 1c760c6..9e35b68 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -488,13 +488,22 @@ async def discover_via_ssh( return result # Step 2: SSH into each rack, parse config, upsert devices - # Build mapping of external_id -> Rack ID for device assignment - rack_id_mapping = {} + # 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 - all_racks = (await db.execute(select(Rack).where(Rack.object_id == obj.id))).scalars().all() - for rack in all_racks: - if rack.external_id: - rack_id_mapping[rack.external_id] = rack.id + rack_id_mapping: dict[str, int] = {} + for ext_id, _, _ 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 + else: + new_rack = Rack(object_id=obj.id, name=ext_id) + 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}' (id={new_rack.id})") for external_id, rack_host, rack_name in rack_hosts: # Resolve config_path override from the embedded device's connection_params From 3460a7ccac48fffa847279bb882993ce346bff0f Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 12:56:51 +0300 Subject: [PATCH 14/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B1=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 129 ++++++++++++++++++++--- 1 file changed, 116 insertions(+), 13 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 9e35b68..266b6f1 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -133,10 +133,10 @@ def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] | logger.info(f"[ssh_discovery] Plugin '{plugin_name}' (cls={cls})") - # Collect all URI-like values in this section - uri_fields = ("uri", "frame_uri", "host") + # 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 uri_fields: + 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)") @@ -164,9 +164,42 @@ def _parse_config(config_text: str, rack_host: str, all_rack_hosts: list[str] | )) 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 @@ -505,6 +538,59 @@ async def discover_via_ssh( rack_id_mapping[ext_id] = new_rack.id logger.info(f"[ssh_discovery] Created rack entry '{ext_id}' (id={new_rack.id})") + # Step 1b: Add main CAPS server and DB server as devices + infra_devices = [] + if obj.server_ip: + infra_devices.append((obj.server_ip, "main_server", "caps_server")) + if obj.db_host and obj.db_host != obj.server_ip: + infra_devices.append((obj.db_host, "vm", "db_server")) + + for infra_ip, infra_category, infra_hostname 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", + )) + logger.info(f"[ssh_discovery] {infra_ip} ({infra_hostname}) - ADDED: category={infra_category}, location=server_room, 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: + 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 external_id, rack_host, rack_name in rack_hosts: # Resolve config_path override from the embedded device's connection_params existing_rack_pc = (await db.execute( @@ -552,6 +638,7 @@ async def discover_via_ssh( category="embedded", role="other", source="auto_ssh", + rack_id=rack_id_mapping.get(external_id), )) logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: category=main_server, role=other, hostname={rack_hostname}, source=auto_ssh") result.created += 1 @@ -564,16 +651,32 @@ async def discover_via_ssh( role="other", )) else: - logger.info(f"[ssh_discovery] {rack_host} ({existing_rack.hostname}) - SKIPPED: rack already exists in devices") - result.skipped += 1 - result.actions.append(DeviceAction( - ip=rack_host, - hostname=existing_rack.hostname, - action="SKIPPED", - reason="rack already exists in devices", - category=existing_rack.category, - role=existing_rack.role, - )) + rack_changes = [] + if existing_rack.rack_id is None and rack_id_mapping.get(external_id) is not None: + existing_rack.rack_id = rack_id_mapping[external_id] + rack_changes.append(f"rack_id: None→{existing_rack.rack_id}") + if rack_changes: + logger.info(f"[ssh_discovery] {rack_host} ({existing_rack.hostname}) - UPDATED: {', '.join(rack_changes)}") + result.updated += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=existing_rack.hostname, + action="UPDATED", + reason="; ".join(rack_changes), + category=existing_rack.category, + role=existing_rack.role, + )) + else: + logger.info(f"[ssh_discovery] {rack_host} ({existing_rack.hostname}) - SKIPPED: rack already exists in devices") + result.skipped += 1 + result.actions.append(DeviceAction( + ip=rack_host, + hostname=existing_rack.hostname, + action="SKIPPED", + reason="rack already exists in devices", + category=existing_rack.category, + role=existing_rack.role, + )) except Exception as exc: logger.error(f"[ssh_discovery] {rack_host} - ERROR adding rack: {exc}") result.errors.append(f"Error adding rack {rack_host}: {exc}") From 0c9ee8b51b954ff2792e7a71e7b0733114eb97ab Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 13:03:41 +0300 Subject: [PATCH 15/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B1=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 45 +++++++++++++++--------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 266b6f1..75ab508 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -542,8 +542,8 @@ async def discover_via_ssh( infra_devices = [] if obj.server_ip: infra_devices.append((obj.server_ip, "main_server", "caps_server")) - if obj.db_host and obj.db_host != obj.server_ip: - infra_devices.append((obj.db_host, "vm", "db_server")) + if db_host and db_host != obj.server_ip: + infra_devices.append((db_host, "vm", "db_server")) for infra_ip, infra_category, infra_hostname in infra_devices: try: @@ -694,9 +694,17 @@ async def discover_via_ssh( logger.info(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}") for found in devices_found: - # Hostname: "{external_id}-{plugin_name}", e.g. "lane-1-1-camera_1" - hostname = f"{external_id}-{found.plugin_name}" - rack_id = rack_id_mapping.get(external_id) + # 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( @@ -730,22 +738,24 @@ async def discover_via_ssh( if existing.role == "other" and found.role != "other": changes.append(f"role: other→{found.role}") existing.role = found.role - # Update hostname if it was never set or still auto-generated - if not existing.hostname or existing.hostname.startswith("rack") or (existing.hostname.startswith("lane") and not existing.hostname.startswith(f"{external_id}")): - 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}") + 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=hostname, + hostname=existing.hostname, action="UPDATED", reason="; ".join(changes), category=found.category, @@ -756,7 +766,7 @@ async def discover_via_ssh( result.skipped += 1 result.actions.append(DeviceAction( ip=found.ip, - hostname=hostname, + 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, @@ -771,9 +781,10 @@ async def discover_via_ssh( 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}, source=auto_ssh") + 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, From 22b88fed1abac07e311e8211526631109c6d06cb Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 13:18:31 +0300 Subject: [PATCH 16/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B1=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 66 ++++++++++++++++++------ 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 75ab508..c2c1604 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -13,6 +13,7 @@ Limitations (devices not discoverable automatically): import logging import re +from datetime import datetime, timezone from configparser import RawConfigParser from dataclasses import dataclass, field from io import StringIO @@ -443,6 +444,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: @@ -490,6 +492,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 " @@ -539,13 +542,15 @@ async def discover_via_ssh( logger.info(f"[ssh_discovery] Created rack entry '{ext_id}' (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")) + 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")) + infra_devices.append((db_host, "vm", "db_server", True)) # DB query succeeded = online - for infra_ip, infra_category, infra_hostname in infra_devices: + for infra_ip, infra_category, infra_hostname, infra_verified in infra_devices: try: existing_infra = (await db.execute( select(Device).where( @@ -562,8 +567,10 @@ async def discover_via_ssh( 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, source=auto_ssh") + 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, @@ -574,16 +581,35 @@ async def discover_via_ssh( role="other", )) 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, - )) + 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}") @@ -639,19 +665,27 @@ async def discover_via_ssh( role="other", source="auto_ssh", rack_id=rack_id_mapping.get(external_id), + status="online", + last_seen=now, )) - logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: category=main_server, role=other, hostname={rack_hostname}, source=auto_ssh") + logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: category=embedded, role=other, hostname={rack_hostname}, status=online, source=auto_ssh") result.created += 1 result.actions.append(DeviceAction( ip=rack_host, hostname=rack_hostname, action="ADDED", - reason="rack main server discovered via SSH connection", + reason="rack PC discovered via SSH connection", category="embedded", role="other", )) else: rack_changes = [] + # SSH succeeded → always update status and last_seen + if existing_rack.status != "online": + existing_rack.status = "online" + rack_changes.append("status: →online") + existing_rack.last_seen = now + rack_changes.append(f"last_seen: →{now.isoformat()}") if existing_rack.rack_id is None and rack_id_mapping.get(external_id) is not None: existing_rack.rack_id = rack_id_mapping[external_id] rack_changes.append(f"rack_id: None→{existing_rack.rack_id}") From b59f0222f098c25fb7bec805ae8af2ba14150ef1 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 13:24:30 +0300 Subject: [PATCH 17/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B1=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 35 ++++++++++++++---------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index c2c1604..63c3ba2 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -632,6 +632,7 @@ async def discover_via_ssh( "config_path", config_path ) + rack_hostname = external_id try: config_text = await _read_remote_config( host=rack_host, @@ -640,13 +641,11 @@ 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 - - # Add the rack itself as a main_server device if it doesn't exist - # Use external_id (e.g., "lane-1-1") as the hostname - rack_hostname = external_id + rack_ssh_ok = False + config_text = None logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_hostname})") try: existing_rack = (await db.execute( @@ -665,27 +664,29 @@ async def discover_via_ssh( role="other", source="auto_ssh", rack_id=rack_id_mapping.get(external_id), - status="online", - last_seen=now, + status="online" if rack_ssh_ok else "unknown", + last_seen=now if rack_ssh_ok else None, )) - logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: category=embedded, role=other, hostname={rack_hostname}, status=online, source=auto_ssh") + status_str = "online" if rack_ssh_ok else "unknown" + logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: category=embedded, role=other, hostname={rack_hostname}, status={status_str}, source=auto_ssh") result.created += 1 result.actions.append(DeviceAction( ip=rack_host, hostname=rack_hostname, action="ADDED", - reason="rack PC discovered via SSH connection", + 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 = [] - # SSH succeeded → always update status and last_seen - if existing_rack.status != "online": - existing_rack.status = "online" - rack_changes.append("status: →online") - existing_rack.last_seen = now - rack_changes.append(f"last_seen: →{now.isoformat()}") + if rack_ssh_ok: + # SSH succeeded → update status and last_seen + if existing_rack.status != "online": + existing_rack.status = "online" + rack_changes.append("status: →online") + existing_rack.last_seen = now + rack_changes.append(f"last_seen: →{now.isoformat()}") if existing_rack.rack_id is None and rack_id_mapping.get(external_id) is not None: existing_rack.rack_id = rack_id_mapping[external_id] rack_changes.append(f"rack_id: None→{existing_rack.rack_id}") @@ -722,6 +723,10 @@ async def discover_via_ssh( category="embedded", role="other", )) + if not rack_ssh_ok: + await db.flush() + continue # no config to parse + # Get all rack IPs to exclude them from device discovery all_rack_ips = [host for _, host, _ in rack_hosts] devices_found = _parse_config(config_text, rack_host, all_rack_ips) From 58c36da2ae8e19c527afee7cfb732b5f48d7790b Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 13:35:12 +0300 Subject: [PATCH 18/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B1=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 63c3ba2..8651513 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -646,15 +646,10 @@ async def discover_via_ssh( result.errors.append(f"SSH to {rack_host} failed: {exc}") rack_ssh_ok = False config_text = None - logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_hostname})") + logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_hostname}), ssh_ok={rack_ssh_ok}, in_db={existing_rack_pc is not None}") + # Reuse existing_rack_pc (queried before SSH) — same identity map object + existing_rack = existing_rack_pc try: - existing_rack = (await db.execute( - select(Device).where( - Device.object_id == obj.id, - Device.ip == rack_host, - ) - )).scalar_one_or_none() - if existing_rack is None: db.add(Device( object_id=obj.id, @@ -724,7 +719,10 @@ async def discover_via_ssh( role="other", )) if not rack_ssh_ok: - await db.flush() + try: + await db.flush() + except Exception as exc: + result.errors.append(f"Flush error for offline rack {rack_host}: {exc}") continue # no config to parse # Get all rack IPs to exclude them from device discovery From 862c6cbe1810d46818b8d28a7bc053dc985a2b8f Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 13:44:37 +0300 Subject: [PATCH 19/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B1=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 52 ++++++++++++------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 8651513..3a285da 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -633,6 +633,8 @@ async def discover_via_ssh( ) rack_hostname = external_id + rack_ssh_ok = False + config_text = None try: config_text = await _read_remote_config( host=rack_host, @@ -644,13 +646,12 @@ async def discover_via_ssh( rack_ssh_ok = True except Exception as exc: result.errors.append(f"SSH to {rack_host} failed: {exc}") - rack_ssh_ok = False - config_text = None + logger.info(f"[ssh_discovery] Processing rack {rack_host} ({rack_hostname}), ssh_ok={rack_ssh_ok}, in_db={existing_rack_pc is not None}") - # Reuse existing_rack_pc (queried before SSH) — same identity map object - existing_rack = existing_rack_pc + try: - if existing_rack is None: + 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, @@ -663,7 +664,7 @@ async def discover_via_ssh( 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: category=embedded, role=other, hostname={rack_hostname}, status={status_str}, source=auto_ssh") + logger.info(f"[ssh_discovery] {rack_host} ({rack_hostname}) - ADDED: status={status_str}") result.created += 1 result.actions.append(DeviceAction( ip=rack_host, @@ -676,40 +677,40 @@ async def discover_via_ssh( else: rack_changes = [] if rack_ssh_ok: - # SSH succeeded → update status and last_seen - if existing_rack.status != "online": - existing_rack.status = "online" + if existing_rack_pc.status != "online": + existing_rack_pc.status = "online" rack_changes.append("status: →online") - existing_rack.last_seen = now - rack_changes.append(f"last_seen: →{now.isoformat()}") - if existing_rack.rack_id is None and rack_id_mapping.get(external_id) is not None: - existing_rack.rack_id = rack_id_mapping[external_id] - rack_changes.append(f"rack_id: None→{existing_rack.rack_id}") + 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.hostname}) - UPDATED: {', '.join(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.hostname, + hostname=existing_rack_pc.hostname, action="UPDATED", reason="; ".join(rack_changes), - category=existing_rack.category, - role=existing_rack.role, + category=existing_rack_pc.category, + role=existing_rack_pc.role, )) else: - logger.info(f"[ssh_discovery] {rack_host} ({existing_rack.hostname}) - SKIPPED: rack already exists in devices") + 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.hostname, + hostname=existing_rack_pc.hostname, action="SKIPPED", - reason="rack already exists in devices", - category=existing_rack.category, - role=existing_rack.role, + reason=reason, + category=existing_rack_pc.category, + role=existing_rack_pc.role, )) except Exception as exc: - logger.error(f"[ssh_discovery] {rack_host} - ERROR adding rack: {exc}") - result.errors.append(f"Error adding rack {rack_host}: {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, @@ -718,6 +719,7 @@ async def discover_via_ssh( category="embedded", role="other", )) + if not rack_ssh_ok: try: await db.flush() From 9d1aafab2372fef73f0cb1300491a5553323e10f Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 14:10:29 +0300 Subject: [PATCH 20/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D1=82=207=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B1=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80?= =?UTF-8?q?=20=D1=81=D0=BB=D0=B5=D0=B9=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 225 +++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 3a285da..4694fdf 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -225,6 +225,68 @@ 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, @@ -524,6 +586,11 @@ async def discover_via_ssh( return result # Step 2: SSH into each rack, parse config, upsert devices + # 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 @@ -727,6 +794,11 @@ async def discover_via_ssh( result.errors.append(f"Flush error for offline rack {rack_host}: {exc}") 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] devices_found = _parse_config(config_text, rack_host, all_rack_ips) @@ -845,6 +917,10 @@ async def discover_via_ssh( 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. # Without this, autoflush=False means pending db.add() calls are @@ -854,6 +930,155 @@ async def discover_via_ssh( except Exception as exc: result.errors.append(f"Flush error after rack {rack_host}: {exc}") + # 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 + ) + if slave_candidates: + logger.info(f"[ssh_discovery] Step 2.5: Slave discovery — {len(slave_candidates)} ARP candidate(s): {slave_candidates}") + for candidate_ip in slave_candidates: + logger.info(f"[ssh_discovery] Checking slave candidate {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_identifier = identifier + slave_config_text = slave_cfg + master_ip_for_slave = master_host + 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 (identifier={slave_identifier}, master={master_ip_for_slave})") + + # 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") + 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 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( From 55c80af9c5a12383b32d10571e4303754f27dcdb Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 14:13:24 +0300 Subject: [PATCH 21/31] =?UTF-8?q?=D1=81=D0=BE=D0=BD=D0=B5=D0=B8=D1=82=207?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80=20=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/device_discovery.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 4694fdf..8a027e6 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -962,16 +962,21 @@ async def discover_via_ssh( ) if ok: is_slave = True - slave_identifier = identifier 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 (identifier={slave_identifier}, master={master_ip_for_slave})") + logger.info(f"[ssh_discovery] {candidate_ip} — SLAVE rack confirmed (name={slave_identifier}, master={master_ip_for_slave})") # Create Rack entry for the slave in our DB slave_rack_id: int | None = None From 56b0eaa495f8af379741f48b225f09a1e3dec9e6 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 15:41:06 +0300 Subject: [PATCH 22/31] =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B1=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- backend/app/routers/devices.py | 12 + backend/app/routers/objects.py | 22 +- frontend/src/api/objects.ts | 16 ++ frontend/src/pages/InventoryObjectDetail.tsx | 228 ++++++++++++++----- 5 files changed, 221 insertions(+), 60 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6259af3..cec2c47 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "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:*)" ] } } 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..2c85cc6 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -132,20 +132,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) diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index dfd2666..0f8dcd6 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -130,6 +130,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 diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx index 2691f35..b8c674d 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -39,6 +39,8 @@ import { useNavigate, useParams } from 'react-router-dom' import { useCreateDevice, useDeleteDevice, + useDeleteAllDevices, + useForceDeleteObject, useDiscoverObject, useUpdateDevice, useDevices, @@ -56,6 +58,13 @@ import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import { stripEmpty } from '../utils/form' import dayjs from 'dayjs' +const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router']) +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: 'Виртуалка / сервис' }, @@ -101,6 +110,8 @@ export function InventoryObjectDetailPage() { 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) @@ -217,6 +228,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 { @@ -276,6 +315,43 @@ 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) addGroup(rackName, `rack:${rackName}`, items) + + if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack) + + return rows + }, [filteredDevices]) + if (objLoading) return const deviceColumns = [ @@ -283,88 +359,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 {d.rack_name} + 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) => ( - - + - 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 }} From c8429814bfb8b86b84083d5ddda6d98d2796a6e9 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 15:58:47 +0300 Subject: [PATCH 23/31] sse ssh discovery --- backend/app/routers/objects.py | 48 ++++- backend/app/schemas/object.py | 4 + backend/app/services/device_discovery.py | 22 +- frontend/src/api/objects.ts | 15 +- frontend/src/components/DiscoveryDrawer.tsx | 210 +++++++++++++++++++ frontend/src/pages/InventoryObjectDetail.tsx | 27 ++- 6 files changed, 300 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/DiscoveryDrawer.tsx diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 2c85cc6..5a9d1ff 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -1,3 +1,5 @@ +import asyncio + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select, distinct from sqlalchemy.ext.asyncio import AsyncSession @@ -9,11 +11,13 @@ from app.models.object import Object from app.models.tag import Tag from app.models.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, 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"]) @@ -171,22 +175,44 @@ async def sync_devices( ) -@router.post("/{obj_id}/discover", response_model=SyncResult) +async def _run_discovery_background(obj_id: int, task_id: str) -> None: + """Run SSH discovery in background, emitting SSE progress events.""" + + async def emit(event_type: str, data: dict) -> None: + await sse_manager.publish(SSEEvent(type=event_type, task_id=task_id, data=data)) + + try: + async with AsyncSessionLocal() as session: + async with session.begin(): + result_row = await session.execute( + select(Object).options(selectinload(Object.tags)).where(Object.id == obj_id) + ) + obj = result_row.scalar_one_or_none() + if obj is None: + await emit("disc_done", {"created": 0, "updated": 0, "skipped": 0, "errors": ["Object not found"]}) + return + discovery_result = await discover_via_ssh(db=session, obj=obj, progress_cb=emit) + await emit("disc_done", { + "created": discovery_result.created, + "updated": discovery_result.updated, + "skipped": discovery_result.skipped, + "errors": discovery_result.errors, + }) + except Exception as exc: + await emit("disc_done", {"created": 0, "updated": 0, "skipped": 0, "errors": [str(exc)]}) + + +@router.post("/{obj_id}/discover", response_model=DiscoveryStarted) async def discover_devices( 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("/{obj_id}/sync/sheets", response_model=SyncResult) diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py index fd1042c..4af65b7 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -90,3 +90,7 @@ class SyncResult(BaseModel): updated: int skipped: int errors: list[str] = [] + + +class DiscoveryStarted(BaseModel): + task_id: str diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index 8a027e6..e2a3a37 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -13,6 +13,7 @@ Limitations (devices not discoverable automatically): import logging import re +from collections.abc import Callable, Awaitable from datetime import datetime, timezone from configparser import RawConfigParser from dataclasses import dataclass, field @@ -477,13 +478,21 @@ class DiscoveryResult: 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 @@ -585,6 +594,8 @@ 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 # 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 @@ -684,7 +695,14 @@ async def discover_via_ssh( if infra_devices: await db.flush() - for external_id, rack_host, rack_name in rack_hosts: + for rack_idx, (external_id, rack_host, rack_name) in enumerate(rack_hosts): + await _emit("disc_rack_start", { + "rack_name": rack_name, + "rack_host": rack_host, + "index": rack_idx, + "total": len(rack_hosts), + }) + # Resolve config_path override from the embedded device's connection_params existing_rack_pc = (await db.execute( select(Device).where( @@ -792,6 +810,7 @@ async def discover_via_ssh( 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) @@ -929,6 +948,7 @@ 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 diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index 0f8dcd6..bc96f67 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -220,15 +220,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 diff --git a/frontend/src/components/DiscoveryDrawer.tsx b/frontend/src/components/DiscoveryDrawer.tsx new file mode 100644 index 0000000..5e2d722 --- /dev/null +++ b/frontend/src/components/DiscoveryDrawer.tsx @@ -0,0 +1,210 @@ +import { + CheckCircleOutlined, + CloseCircleOutlined, + LoadingOutlined, + MinusCircleOutlined, +} from '@ant-design/icons' +import { Drawer, Progress, Space, Tag, Typography } from 'antd' +import { fetchEventSource } from '@microsoft/fetch-event-source' +import { useEffect, useRef, useState } from 'react' +import { useAuthStore } from '../store/auth' + +interface RackStatus { + rack_name: string + rack_host: string + state: 'pending' | 'running' | 'done' | 'error' + found: number + ssh_ok: boolean +} + +interface DiscoveryResult { + created: number + updated: number + skipped: number + errors: string[] +} + +interface Props { + open: boolean + onClose: () => void + objectName: string + taskId: string | null + onComplete?: () => void +} + +export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) { + const accessToken = useAuthStore((s) => s.accessToken) + const [phase, setPhase] = useState<'connecting' | 'running' | 'done'>('connecting') + const [racks, setRacks] = useState([]) + const [current, setCurrent] = useState(0) + const [total, setTotal] = useState(0) + const [result, setResult] = useState(null) + const ctrlRef = useRef(null) + + useEffect(() => { + if (!open || !taskId || !accessToken) return + + setPhase('connecting') + setRacks([]) + setCurrent(0) + setTotal(0) + setResult(null) + + const ctrl = new AbortController() + ctrlRef.current = ctrl + + fetchEventSource(`/api/v1/events?task_id=${taskId}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: ctrl.signal, + onmessage(ev) { + try { + const data = JSON.parse(ev.data) + + if (ev.event === 'disc_server') { + setTotal(data.rack_count) + setPhase('running') + + } else if (ev.event === 'disc_rack_start') { + setTotal(data.total) + setCurrent(data.index) + setRacks((prev) => { + if (prev.find((r) => r.rack_name === data.rack_name)) { + return prev.map((r) => + r.rack_name === data.rack_name ? { ...r, state: 'running' } : r + ) + } + return [ + ...prev, + { rack_name: data.rack_name, rack_host: data.rack_host, state: 'running', found: 0, ssh_ok: false }, + ] + }) + + } else if (ev.event === 'disc_rack_done') { + setCurrent((c) => c + 1) + setRacks((prev) => + prev.map((r) => + r.rack_name === data.rack_name + ? { ...r, state: data.ssh_ok ? 'done' : 'error', found: data.found ?? 0, ssh_ok: data.ssh_ok } + : r + ) + ) + + } else if (ev.event === 'disc_done') { + setResult({ + created: data.created ?? 0, + updated: data.updated ?? 0, + skipped: data.skipped ?? 0, + errors: data.errors ?? [], + }) + setPhase('done') + setCurrent(data.rack_count ?? total) + ctrl.abort() + onComplete?.() + } + } catch { + // ignore parse errors + } + }, + onerror() { + ctrl.abort() + }, + }) + + return () => ctrl.abort() + }, [open, taskId, accessToken]) + + const percent = total > 0 ? Math.round((current / total) * 100) : 0 + + const rackIcon = (state: RackStatus['state']) => { + if (state === 'running') return + if (state === 'done') return + if (state === 'error') return + return + } + + return ( + + {/* Connecting phase */} + {phase === 'connecting' && ( + + + Подключение к серверу... + + )} + + {/* Progress bar */} + {phase !== 'connecting' && ( +
+ + {phase === 'done' ? 'Завершено' : `Обход стоек: ${current} / ${total}`} + + +
+ )} + + {/* 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 ошибка} +
+ ))} +
+ )} + + {/* 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 b8c674d..acacdd8 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -35,6 +35,7 @@ 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, @@ -55,6 +56,7 @@ import { import { useRacks, useCreateRack, 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' @@ -93,6 +95,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) @@ -118,6 +121,8 @@ export function InventoryObjectDetailPage() { 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 [csvFile, setCsvFile] = useState(null) const [csvPreview, setCsvPreview] = useState(null) const [searchText, setSearchText] = useState('') @@ -143,18 +148,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() @@ -813,6 +818,14 @@ export function InventoryObjectDetailPage() { + setDiscoveryOpen(false)} + objectName={obj?.name ?? ''} + taskId={discoveryTaskId} + onComplete={handleDiscoveryComplete} + /> + {/* Google Sheets Sync Modal */} Date: Wed, 8 Apr 2026 16:18:34 +0300 Subject: [PATCH 24/31] sse ssh discovery drawer upgrade --- backend/alembic/versions/0010_rack_type.py | 22 +++ backend/app/models/rack.py | 1 + backend/app/schemas/device.py | 2 + backend/app/schemas/rack.py | 3 + backend/app/services/device_discovery.py | 18 ++ frontend/src/api/objects.ts | 1 + frontend/src/api/racks.ts | 3 +- frontend/src/components/DiscoveryDrawer.tsx | 169 +++++++++++++++---- frontend/src/pages/InventoryObjectDetail.tsx | 25 ++- 9 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 backend/alembic/versions/0010_rack_type.py diff --git a/backend/alembic/versions/0010_rack_type.py b/backend/alembic/versions/0010_rack_type.py new file mode 100644 index 0000000..641caf2 --- /dev/null +++ b/backend/alembic/versions/0010_rack_type.py @@ -0,0 +1,22 @@ +"""add rack_type to racks + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-04-08 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("racks", sa.Column("rack_type", sa.String(50), nullable=True)) + + +def downgrade() -> None: + op.drop_column("racks", "rack_type") diff --git a/backend/app/models/rack.py b/backend/app/models/rack.py index d5f3971..59978e6 100644 --- a/backend/app/models/rack.py +++ b/backend/app/models/rack.py @@ -17,6 +17,7 @@ class Rack(Base, TimestampMixin): index=True, ) name: Mapped[str] = mapped_column(String(100), nullable=False) + rack_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index cda854d..b7ef0f7 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -71,6 +71,7 @@ class DeviceRead(BaseModel): external_id: Optional[str] = None rack_id: Optional[int] = None rack_name: Optional[str] = None + rack_type: Optional[str] = None ssh_user_override: Optional[str] = None ssh_port_override: Optional[int] = None device_meta: dict[str, Any] = {} @@ -91,6 +92,7 @@ class DeviceRead(BaseModel): obj = cls.model_validate(device) if device.rack is not None: obj.rack_name = device.rack.name + obj.rack_type = device.rack.rack_type return obj diff --git a/backend/app/schemas/rack.py b/backend/app/schemas/rack.py index 81c9548..0ab5927 100644 --- a/backend/app/schemas/rack.py +++ b/backend/app/schemas/rack.py @@ -6,6 +6,7 @@ from pydantic import BaseModel class RackCreate(BaseModel): name: str + rack_type: Optional[str] = None description: Optional[str] = None location: Optional[str] = None zone_id: Optional[int] = None @@ -13,6 +14,7 @@ class RackCreate(BaseModel): class RackUpdate(BaseModel): name: Optional[str] = None + rack_type: Optional[str] = None description: Optional[str] = None location: Optional[str] = None zone_id: Optional[int] = None @@ -22,6 +24,7 @@ class RackRead(BaseModel): id: int object_id: int name: str + rack_type: Optional[str] = None description: Optional[str] = None location: Optional[str] = None zone_id: Optional[int] = None diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py index e2a3a37..a4fc6e7 100644 --- a/backend/app/services/device_discovery.py +++ b/backend/app/services/device_discovery.py @@ -525,6 +525,7 @@ async def discover_via_ssh( return result elif obj.server_ip: # Autodetect: SSH into server, read its caps.conf to get DB URI + await _emit("disc_action", {"message": f"Чтение конфигурации сервера {obj.server_ip}..."}) detected = await _autodetect_db_from_server( server_ip=obj.server_ip, ssh_port=ssh_port, @@ -572,6 +573,7 @@ async def discover_via_ssh( return result # Step 1: get rack hosts from object's DB + await _emit("disc_action", {"message": "Получение списка стоек из БД..."}) try: rack_hosts = await _get_rack_hosts_direct( host=db_host, @@ -720,6 +722,7 @@ async def discover_via_ssh( rack_hostname = external_id rack_ssh_ok = False config_text = None + await _emit("disc_action", {"message": f"SSH → {rack_host} ({rack_name})"}) try: config_text = await _read_remote_config( host=rack_host, @@ -820,6 +823,7 @@ async def discover_via_ssh( # Get all rack IPs to exclude them from device discovery all_rack_ips = [host for _, host, _ in rack_hosts] + await _emit("disc_action", {"message": f"Парсинг конфигурации {rack_name}..."}) devices_found = _parse_config(config_text, rack_host, all_rack_ips) logger.info(f"[ssh_discovery] Found {len(devices_found)} devices from plugins in {rack_host}") @@ -962,10 +966,15 @@ async def discover_via_ssh( - infra_ips - common_gateways ) + await _emit("disc_phase", {"phase": "slaves", "candidate_count": len(slave_candidates)}) if slave_candidates: logger.info(f"[ssh_discovery] Step 2.5: Slave discovery — {len(slave_candidates)} ARP candidate(s): {slave_candidates}") + await _emit("disc_action", {"message": f"Поиск слейв-стоек: проверяем {len(slave_candidates)} ARP-кандидатов..."}) + else: + await _emit("disc_action", {"message": "Слейв-стойки: ARP-кандидатов не найдено"}) for candidate_ip in slave_candidates: logger.info(f"[ssh_discovery] Checking slave candidate {candidate_ip}...") + await _emit("disc_action", {"message": f"Проверяем слейв-кандидата {candidate_ip}..."}) # Check each rack host that could be the master — slave's linking.uri must match is_slave = False slave_identifier = "" @@ -997,6 +1006,7 @@ async def discover_via_ssh( continue logger.info(f"[ssh_discovery] {candidate_ip} — SLAVE rack confirmed (name={slave_identifier}, master={master_ip_for_slave})") + await _emit("disc_action", {"message": f"Слейв подтверждён: {slave_identifier} ({candidate_ip})"}) # Create Rack entry for the slave in our DB slave_rack_id: int | None = None @@ -1063,6 +1073,7 @@ async def discover_via_ssh( all_rack_ips_with_slave = list(all_rack_ips_set) + [candidate_ip] slave_devices = _parse_config(slave_config_text, candidate_ip, all_rack_ips_with_slave) logger.info(f"[ssh_discovery] Slave {candidate_ip}: found {len(slave_devices)} plugin devices") + await _emit("disc_slave_found", {"rack_name": slave_identifier, "ip": candidate_ip, "found": len(slave_devices)}) for found in slave_devices: is_shared = found.category == "vm" if is_shared: @@ -1105,6 +1116,8 @@ async def discover_via_ssh( result.errors.append(f"Flush error after slave {candidate_ip}: {exc}") # Step 3: Discover MikroTik via SSH_CLIENT + await _emit("disc_phase", {"phase": "mikrotik"}) + await _emit("disc_action", {"message": "Поиск MikroTik (SSH_CLIENT)..."}) logger.info(f"[ssh_discovery] Step 3: Attempting to discover MikroTik via SSH_CLIENT on {obj.server_ip or 'main_server'}") mikrotik_ip = await _discover_mikrotik_via_ssh_client( host=obj.server_ip or rack_hosts[0][1], @@ -1114,6 +1127,7 @@ async def discover_via_ssh( ) if mikrotik_ip: logger.info(f"[ssh_discovery] MikroTik found via SSH_CLIENT at {mikrotik_ip}") + await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip}) try: existing = (await db.execute( select(Device).where( @@ -1169,6 +1183,8 @@ async def discover_via_ssh( logger.info(f"[ssh_discovery] MikroTik: No IP found via SSH_CLIENT (reason: SSH_CLIENT env var not available or parsing failed)") # Step 4: Discover Proxmox via port 8006 scan + await _emit("disc_phase", {"phase": "proxmox"}) + await _emit("disc_action", {"message": "Сканирование порта 8006 (Proxmox)..."}) logger.info(f"[ssh_discovery] Step 4: Attempting to discover Proxmox servers via port 8006 scan on {obj.server_ip or 'main_server'}") proxmox_ips = await _discover_proxmox_via_port_scan( host=obj.server_ip or rack_hosts[0][1], @@ -1178,6 +1194,8 @@ async def discover_via_ssh( ) if proxmox_ips: logger.info(f"[ssh_discovery] Proxmox discovery found {len(proxmox_ips)} server(s) on port 8006: {proxmox_ips}") + for proxmox_ip in proxmox_ips: + await _emit("disc_infra_found", {"type": "proxmox", "ip": proxmox_ip}) for idx, proxmox_ip in enumerate(proxmox_ips, 1): hostname = f"proxmox_{idx}" try: diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index bc96f67..8d36fb5 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -32,6 +32,7 @@ export interface DeviceItem { source: string rack_id: number | null rack_name: string | null + rack_type: string | null ssh_user_override: string | null ssh_port_override: number | null status: string diff --git a/frontend/src/api/racks.ts b/frontend/src/api/racks.ts index 9538ba7..8c058ba 100644 --- a/frontend/src/api/racks.ts +++ b/frontend/src/api/racks.ts @@ -5,6 +5,7 @@ export interface RackItem { id: number object_id: number name: string + rack_type: string | null description: string | null location: string | null zone_id: number | null @@ -22,7 +23,7 @@ export const useRacks = (objId: number) => export const useCreateRack = (objId: number) => { const qc = useQueryClient() return useMutation({ - mutationFn: (body: { name: string; description?: string; location?: string; zone_id?: number | null }) => + mutationFn: (body: { name: string; rack_type?: string; description?: string; location?: string; zone_id?: number | null }) => api.post(`/api/v1/objects/${objId}/racks`, body).then((r) => r.data), onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }), }) diff --git a/frontend/src/components/DiscoveryDrawer.tsx b/frontend/src/components/DiscoveryDrawer.tsx index 5e2d722..847c5e6 100644 --- a/frontend/src/components/DiscoveryDrawer.tsx +++ b/frontend/src/components/DiscoveryDrawer.tsx @@ -17,13 +17,19 @@ interface RackStatus { ssh_ok: boolean } -interface DiscoveryResult { - created: number - updated: number - skipped: number - errors: string[] +interface SlaveItem { + rack_name: string + ip: string + found: number } +interface InfraItem { + type: 'mikrotik' | 'proxmox' + ip: string +} + +type PhaseState = 'idle' | 'running' | 'done' + interface Props { open: boolean onClose: () => void @@ -34,20 +40,32 @@ interface Props { export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) { const accessToken = useAuthStore((s) => s.accessToken) - const [phase, setPhase] = useState<'connecting' | 'running' | 'done'>('connecting') + const [phase, setPhase] = useState<'connecting' | 'racks' | 'slaves' | 'infra' | 'done'>('connecting') + const [currentAction, setCurrentAction] = useState(null) const [racks, setRacks] = useState([]) const [current, setCurrent] = useState(0) const [total, setTotal] = useState(0) - const [result, setResult] = useState(null) + const [slaves, setSlaves] = useState([]) + const [slavesPhase, setSlavesPhase] = useState('idle') + const [mikrotikPhase, setMikrotikPhase] = useState('idle') + const [proxmoxPhase, setProxmoxPhase] = useState('idle') + const [infraFound, setInfraFound] = useState([]) + const [result, setResult] = useState<{ created: number; updated: number; skipped: number; errors: string[] } | null>(null) const ctrlRef = useRef(null) useEffect(() => { if (!open || !taskId || !accessToken) return setPhase('connecting') + setCurrentAction(null) setRacks([]) setCurrent(0) setTotal(0) + setSlaves([]) + setSlavesPhase('idle') + setMikrotikPhase('idle') + setProxmoxPhase('idle') + setInfraFound([]) setResult(null) const ctrl = new AbortController() @@ -60,9 +78,12 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete try { const data = JSON.parse(ev.data) - if (ev.event === 'disc_server') { + if (ev.event === 'disc_action') { + setCurrentAction(data.message) + + } else if (ev.event === 'disc_server') { setTotal(data.rack_count) - setPhase('running') + setPhase('racks') } else if (ev.event === 'disc_rack_start') { setTotal(data.total) @@ -89,6 +110,25 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete ) ) + } else if (ev.event === 'disc_phase') { + if (data.phase === 'slaves') { + setPhase('slaves') + setSlavesPhase('running') + } else if (data.phase === 'mikrotik') { + setPhase('infra') + setSlavesPhase((s) => s === 'running' ? 'done' : s) + setMikrotikPhase('running') + } else if (data.phase === 'proxmox') { + setMikrotikPhase((s) => s === 'running' ? 'done' : s) + setProxmoxPhase('running') + } + + } else if (ev.event === 'disc_slave_found') { + setSlaves((prev) => [...prev, { rack_name: data.rack_name, ip: data.ip, found: data.found }]) + + } else if (ev.event === 'disc_infra_found') { + setInfraFound((prev) => [...prev, { type: data.type, ip: data.ip }]) + } else if (ev.event === 'disc_done') { setResult({ created: data.created ?? 0, @@ -96,8 +136,11 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete skipped: data.skipped ?? 0, errors: data.errors ?? [], }) + setSlavesPhase((s) => s === 'running' ? 'done' : s) + setMikrotikPhase((s) => s === 'running' ? 'done' : s) + setProxmoxPhase((s) => s === 'running' ? 'done' : s) setPhase('done') - setCurrent(data.rack_count ?? total) + setCurrentAction(null) ctrl.abort() onComplete?.() } @@ -122,6 +165,12 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete return } + const phaseIcon = (state: PhaseState) => { + if (state === 'running') return + if (state === 'done') return + return + } + return ( - {/* Connecting phase */} - {phase === 'connecting' && ( - - - Подключение к серверу... - + {/* Current action */} + {(phase === 'connecting' || currentAction) && ( +
+ {phase === 'connecting' && !currentAction ? ( + + + Запуск discovery... + + ) : currentAction ? ( + + + {currentAction} + + ) : null} +
)} - {/* Progress bar */} - {phase !== 'connecting' && ( + {/* Rack progress bar */} + {(phase === 'racks' || phase === 'slaves' || phase === 'infra' || phase === 'done') && (
- {phase === 'done' ? 'Завершено' : `Обход стоек: ${current} / ${total}`} + Стойки: {Math.min(current, total)} / {total} = total ? 100 : percent} status={phase === 'done' ? 'success' : 'active'} style={{ marginBottom: 0 }} /> @@ -158,33 +216,72 @@ export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete {racks.map((r) => (
{rackIcon(r.state)} - {r.rack_name} - - {r.rack_host} - + {r.rack_name} + {r.rack_host} {r.state === 'done' && ( - - {r.found} уст. - + {r.found} уст. )} - {r.state === 'error' && SSH ошибка} + {r.state === 'error' && SSH ошибка}
))}
)} + {/* Slave phase */} + {slavesPhase !== 'idle' && ( +
+
0 ? 6 : 0 }}> + {phaseIcon(slavesPhase)} + + Слейв-стойки + {slaves.length > 0 && {slaves.length} найдено} + +
+ {slaves.map((s) => ( +
+ + {s.rack_name} + {s.ip} + {s.found} уст. +
+ ))} +
+ )} + + {/* MikroTik phase */} + {mikrotikPhase !== 'idle' && ( +
+ {phaseIcon(mikrotikPhase)} + MikroTik (SSH_CLIENT) + {infraFound.filter((i) => i.type === 'mikrotik').map((i) => ( + {i.ip} + ))} + {mikrotikPhase === 'done' && infraFound.filter((i) => i.type === 'mikrotik').length === 0 && ( + не найден + )} +
+ )} + + {/* Proxmox phase */} + {proxmoxPhase !== 'idle' && ( +
+ {phaseIcon(proxmoxPhase)} + Proxmox (порт 8006) + {infraFound.filter((i) => i.type === 'proxmox').map((i) => ( + {i.ip} + ))} + {proxmoxPhase === 'done' && infraFound.filter((i) => i.type === 'proxmox').length === 0 && ( + не найдено + )} +
+ )} + {/* Final result */} {phase === 'done' && result && ( -
+
+{result.created} добавлено {result.updated} обновлено diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx index acacdd8..0fb5a26 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -61,6 +61,9 @@ import { stripEmpty } from '../utils/form' import dayjs from 'dayjs' const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router']) + +const rackLabel = (name: string, type: string | null | undefined): string => + type ? `${name} ${type}` : name const DEVICE_COL_SPAN = 8 type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number } @@ -350,7 +353,10 @@ export function InventoryObjectDetailPage() { if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers) const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b)) - for (const [rackName, items] of sortedRacks) addGroup(rackName, `rack:${rackName}`, items) + for (const [rackName, items] of sortedRacks) { + const label = rackLabel(rackName, items[0]?.rack_type) + addGroup(label, `rack:${rackName}`, items) + } if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack) @@ -396,7 +402,7 @@ export function InventoryObjectDetailPage() { render: (_: unknown, row: TableRow) => { if (row._type === 'group') return null const d = row as DeviceRow - if (d.rack_name) return {d.rack_name} + if (d.rack_name) return {rackLabel(d.rack_name, d.rack_type)} const loc = (d as any).location if (loc === 'server_room') return Серверная if (loc === 'street') return Улица @@ -495,7 +501,13 @@ export function InventoryObjectDetailPage() { ] const rackColumns = [ - { title: 'Название', dataIndex: 'name', key: 'name' }, + { + title: 'Название', + key: 'name', + render: (_: unknown, row: RackItem) => ( + {row.name}{row.rack_type ? {row.rack_type} : null} + ), + }, { title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? {v} : '—' }, { title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' }, { @@ -653,10 +665,13 @@ export function InventoryObjectDetailPage() { Стойки
- + + + + - ({ value: z.id, label: z.name }))} /> From 3b63d2c5337c508baf0cfba870861030ee1a9df4 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 16:48:28 +0300 Subject: [PATCH 25/31] =?UTF-8?q?=D1=85=D0=B7=20=D1=83=D0=B6=D0=B5=20?= =?UTF-8?q?=D1=87=D0=B5=20=D1=82=D1=83=D1=82=20=D0=BF=D1=80=D0=B8=D0=B4?= =?UTF-8?q?=D1=83=D0=BC=D1=8B=D0=B2=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/InventoryObjectDetail.tsx | 80 ++++++++++++++++---- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx index 0fb5a26..7b2ef9a 100644 --- a/frontend/src/pages/InventoryObjectDetail.tsx +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -53,7 +53,7 @@ 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' @@ -113,6 +113,7 @@ 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) @@ -126,6 +127,7 @@ export function InventoryObjectDetailPage() { 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('') @@ -134,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 })), @@ -315,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() @@ -504,27 +527,52 @@ export function InventoryObjectDetailPage() { { title: 'Название', key: 'name', - render: (_: unknown, row: RackItem) => ( - {row.name}{row.rack_type ? {row.rack_type} : null} - ), + render: (_: unknown, row: RackItem) => { + if (editRack?.id === row.id) { + return ( + + {row.name} + + + + + + + + + + + + ) + } + 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('Ошибка удаления стойки'))} - > - + + + + )}
@@ -230,6 +290,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 */} =!(%sS}DcvdENJuwGmvpy;ba$sngLFxE$AX2#g1Z*z-tK+= z|D1ErJ@-E6dG0?IChJ?{d&l>FG3FX`j5TE?pkXi`z{A5opjHoj`{1_&2mH5_IlY06 zuA`}?vF)!v80nlW%u~N$EJoLsvd8lwl_=F!m$@NQJI@!uj zJ9HBCD&R(H%D0B~$+#*mI+$qI|d#IUd>ThhIItnS-GMUd2(>?NZK& zy#{eVX1{rMHs2%P(l?>qGmp{z@P$k#O}(H?Pe3`DR!g*@h~t4*tqJrCul}Ao_8+VB zS*74fHt<`lau=o zwVE4le)J#gZlCps^v_@HFS@nVUAnl~UUe6yYinw2?Mw5`w{GpmtALKJJ!%yQeeGKt2WODt{%*Sb!R}0VVaRUCFdIFe zmYaK#-4zmV%Pi=^+VR$Zr;_dTjM!OJTc*?lw0}ZhOGoFU4eBu0N@}Z-4OB2WC`OQ|>c6V~+ zq9b400_f|lowcp)@!7%Q;VtR+@8apDbT^KU9(GrNQb%nEf!bAenxUJ}#pdE;Prl~!^lTSRlPu zko-fT_cw*4>FuV)Z5r3{IY64aL2fD)@Kfr(v<8?J)9_)HemR##f0_XpKvhlG!Y>rF z##t}7-y=8VAX07cHEqS1G-lv|G<{Hjc;CA-A}!qGR?69<}-07iq`t#gpFhgD4(yPW&13(l8!sUKf!$ z+CIWQo6_gg*2DU>Z>iF`sj{T74N3ddV?rtB%1_)jMSIJlUm!$1oyg)k94|NS6&-fk zEM^L<_)Z<*uyEWnG}lKS-Uk*{pc&HkiNx%yI=288p$v1K8K-S?R!34Dccv?i$n)i6 z1Y*{;IH~}IYU1nB(ks~%)N+%V=0Y=Jv721Cw2BT%a6UW%ocQz3xnVpZ4rnyOxI$_U zgc>gRGtO_~Q@=>aDQ6Z5&jeQN=P((a(;7G=A^Y&;{%l|9fW`u8e-JCkh1`*&$h%Eq z8T=Q6UK@iA>cPeOB3&AmqD$%&3}&#$4?N|r07UU}1*K;e38{rtpc>9e8Gbv^fZu1~ z6XEFe#@U&OWG;e{lY1l=_o62=P1Hea)P&qxF(#Jco!qNT^TvG1S$BlbsT;i%#;Pb1 z&73y=u{u;>hgLw(j01XxBslj;(ta6Y!4@mmXhl>#2LEgw5kap&MRcePP4S*);T_Gw z)GtcE(R_DKTjh|%mX)F_V1BmKgRAUaNZ~`#k7S@SOw2{UT^;>{s!u$ongUO*W?s>M zztwPBtJ1j2ShYJV9hJj=54R>Bks4wP9PT8bhg>*H*R{9o51ulH(>KRH!*tCwrkByf zTDGPY#@gLkn$|l#qK;8k5UYTMi!S)v!E19`C&j;l1ys1GIaE_&sU&kXa^RlD1jmZ` z{4%J(BfB6yKMGl?H@6_l-4#Tk#3wO{47u!DIt#}CllC+W_Uo&oubY{=`M z#P!#mRnT^WHHA>MJTew zL}bXO#*rJSo+~>1<8`WeslyKGyAVi52uuRGM=^V6n++`f2t;m$gSM&)3*V+rLA&TLQ&+`N4Z0e{^*WTqn{kneCQc+@3Gt}$Qbu6ZW&uzHZTSFZ+a_`YB zSFlG7PPLdC{`4qrvsDpwQlo9TrZu?lZUqY!lGb};JkBA;95$~x{%OpKE4zs1Kim{oV|(SPg`5y$X^&tbuF3 z8yXnk&+=NpjNA7yI?)>jhKV+=fX9fdPuL2(6XPx%to3P0Z)J;B6n9A{T%@YC6pK}q zb`2*KW_ZiY30R`BUBOYfOQJcrUSK9-Ce3+YM#%?r`dAqVqn`!;f_k{IvE$`bxa-XDnvj*+)WFT$jEsH# zPc^r^YYx678oSpi6n_S3Rk0?VW=?c)ZSXEg zUHSUMKshiRcyNHPXoi$jP55saAZ>WnQ{g`TePM5A;rM3;+@piX@CK-gW<}nhKbt!g zaUVYE2g)%!R~oziELis#b9!)5F?pLGLK1#I>(A(ff`eIMPQV(?<_ab@0Z#g9nLx<< z;CyslmsR1fiSHwa829IJucyq9(7asT`x~7ZTpcFnN>Htz7A`u5X;itl*?lE@s zzY5%%YF;$%Aj=W9shMPe(Nw$XH881+;^Ho(Fc;vEYLhIA_MZQ$KfK%%cY_OFm#OZ# zkdEikKnVPkkf@`3_#+5Bnn#T9Z}7{aaYtE>XHDPB1f^!NOC!El3Np3axss`6GqOeO zYk1W0AH^hXc-_O~aavMbi|x-rwjZJ*Hgpwrau0q20hjlPiTMqDojmR&+IGFU8OLfl zX)k6k$vj&U?G)0U4CRz3{JW>Hw@1{cKNL)dAR;9mEC)<4OzW-69uVT%h^7t-fj}1JAiHpl)WrQvWe}%x53P2ur zp>PTu8(mv_TU=UvEIu=*(k$_cuoW^YB=R4DGy0jvg-{IguP|^ar`7ty-o`Y6s>C=r z^0hQp>sITXgd7H{6iCVZ48dTHo+6GTBE^z-uaN($9yAW(pp40NJd zqg^6#FdA!72kAkDiM-D65n(%^XJ2@Km=AT`=eJ@2=@Ak+=7F1EuGgi>&YUwz7Tfjo{^Fz6o(|0q92?A87 z+~~VBH1Vt*d7rpfrnZOfyawTr07aF=DW;)dx$^2Y#d(o28of(X~5U*&ru-=a{!D8&5qp(uZ{WDm8Aa zna$Y%rQmPQE+C`4;ZgbC`Hop3Ac8r+M|!+#f}?-rSx z6)a@yALf?lnV6ltn^~2G{RLjY);Z0kNJ9nb6UeUE&b{WGGugY5u`|d3OhXO{-XLm? z<3UnlKz4@s*E!FH`D1fPM`osTPpM(JAm)JJ9VPsN1w{QriLyKs{Cjvn#GUN{5O}Lg zda9&=J4(Nc4~PDt*edHEiVq!siC@Z$tpa2X0kUUCI9H+D!oq-vI|tyOfP-poj&BAw zrPZg&^Fy&yDq}SH5_%|@jou?;$ z-~Ms)GIL1tUTgSYelZ(D9P7u%!uuQlBnSv>+a1XBfIm$cU}$#BD9Hdjm$aMccT221 z4>Es>Pt3J>k?p(92^-G08>4d_USvg@WG3-o@ysC6O|nXr#Dxl<^SXPUWEbhP;7BP} zie{6~4VYb+kH4r~-de~#lu0k!n@uX3tehz&qfIwwE?h^KcN`GOc1>^LyLwY+Wvjfa zT$%32H)EvSznOiI@KhWE2L!xd+$fqXHsV9|tQahclaZKb#EU<0zr5MYU1=FqE7lm) zN(UGutI?(>Ht4@%nZG&(&AhX7ORiL}O==e3?0r?K&dUKd+YW5@Ri(<#k5|UkX;Ljg zCwDsOU>$yl&bD0Ni!i%tOI9B7$Zjo{oZg8F#vO>&;qnK8*@gCn+j7C&W~0nf#Wg;a ziknh=z$<8k#hk*k!d|%o;>sA~q_?ywpE<2A%T*TX?|Xm#nUhJv8I07SRshH$aNWP` zsO2a;Gq(X5#`7^o8~oab%8bk-a6RHV_)kU>a^|W!q+&$wvk!6ncMjK|IALGi_I0BIU&7 z{H65WoyQ>t6@DT&Z&05y;@jNm$+FcPTQggxnQ$Eoe~Yj`mom{Xv6rj)!kMJ_PX+4C z-@T_+dglvBw;_M=MINs#J&Ubko%#Aki6DHBa`>pl3fL=={4Cf9=N`99n0Wg+nj9y&!|duBehQ9N1vlfi71+WxZI z&P0#16A~_mG5!-~2YlY!(A0q87y6sas|VB0KN46S@Ndp}!M|#mz`uC#xU9pCEd9A{ zhQ9kd?tS4&sz2MB4svC&ZR{#XKR4M>w$mp+7#Z0A_Eq|OR-%SYXlk7(Gn?D9J2g%g zhZX$6?>~(06fgSj6rm&T6v;5|6hl$&6zNyPA&T1Y1Q5lR7;cE78>0h6@xo>Uq6mO( zzf+Vj(}B)p7`KEoIE><-F;BJMAx>%EpFm*{ z{Hf&o6UY5&>dx9J`}-66{i*!pT}zp}Q~KPUdOG&~N#RZ}J^Zd^djEYBxVzJH7l^IP z=@3?kj(2Wz8V1|#%OM#4KlOiG3X#qb3WG6x0yE42F4P9%LtFn39D@H6-}^*p0Qcnv zBC4%_Acx?W6e7JL6h`B~1m=HNSq^htY4hR%>IgE2({GsXb!lMO^zTYqm3!GDSG zeWEykOSOTBY3m=%A=s6Yp*JMUXq=b8{4eoOpLC{V=nj2jFg8hGW*NYJwt@K6*598) z@L%G4pV)N+J}pn2(KpTy-me|gmkteC)x@(IV{cB?dfGc*0xaEK&HytPXCdiymX_Mi zS4r`;n0(rAzwcS@5-ft2rHHx#Z-3}Tf4K)+yJ(#?yiZ_bE_Zw zw+|t^Vz7t!^ZbY&JYW`l`0wnBA->;W^xIYrZ5u=L-#2v7L0*RrOFZddKzX!;?i9e_ z8a?S3$g(fuYxx;pM=S@~9G~QPWG*>0bE#u}VA!own-!V463>sFgF0gkmZ73=yB7&< zXx_RR8Vg-Lw6c;!Hv?X(xj>plzmLs2#mxY>T&^P_Y7qf-LhKQ-W16|N3pQd7x4(ds zBKC7J^I$#dj@*Z5@xAJ_L{$wileB3!<(i8!!AR1YPiIb7{k}astXCytM2sPMe>2XSv@QG(TsFH--6ViF}I-z9IaJ z`@&p=BFQUnV5Z5pRaWAWSExZ4Vwc})>FvwV6tXsK>CHpNle$;|fCrvSdmLA*o5{Ob z;UR+#^;)R}0nszZBYO7rE$Sr;sRXwG0zQwa@bH?(iybqMW-Z#G@Y~91{M+LdWaIS7 zWbK+m?2!49mUc5&k86+GMt6IchGw_cxgR%B;&VfW<3p~zE>)oC-}VWy0S4&_EL-Plo91PX0Vgi?SHqyU zG8VjP1fJ7HT0FoX2c{nzEwuItZYR2vcj>#*1#9ZlUkq4twj|xyQrv3iGH^Xz{-l(~ z$A`r`d3};U7Fz8rzIIG$W9_hbk~`QnvwuCmau_ntXXQDYqLq@$JMVeh*%}ujGq-r{ zW_Qj4a&Ep^KlqwnIuvf)ErSQD0d`;Ud(J-B^q4-FWb;&~S!`b1*GB3VRSBsDAWt<; z?e2QgmpUAtpRY%!q#fMOU5+VObJQPt)VC}w+=8a}`W?DO+4?6sTLHu8w;nD(q&~{v zNn4>2um#B^ugx5qN8z*ew(|BJTFAF>i@VR}q*uN7Y{Lg`-^ey69&TV8C!ghJ4T5SM z8}R}2S6`RQ=lCWy2hXoEL05v)GOewyjjdO!!cUI(PFFQoLFw+Lx&GVby6mRw>tWj& z`j-XzE;~-zr=13JHw{~7267h-TZ0C2sS94$WzZS7rNmJL&o)Y*)%aYHhg`N_M+>j& z+~jAnC#SXcT(4*7UKHrECwNv+;1BRAotoeDO4AQ$8{Tl*^CPA6MGFTEr{`xH17sXr zbZ^K2wL#D}3?Rif+)jF}X*30JV=`FH4DRvUSAb6I_o|)=6mO1~U{72sw%%X%W@6_M zFoB#r*pZNSkhLe9?UqaL6~N6kx?4tu%bDTcP5jOa5|SD24M&0KM;aLFd%5!2}>RVehPCQ`PC3 zP-1z_(+wq{J3Q`EssGhHlgHy)Nsqep8+Dv&22s7M^h$Aju-@!DX=7F_F_oo zm@wUmCJYM56j&1rdKIN|%$Ss-^OGbYqA`}loL&~G98)H#XwxLgF@vOgk)2e|J0{KO z?Ig)5g9frO*2I$DJCR^CZqmRv1Aj7Rmc)!+L@6T^CWuHf!IVWeXT~?tAKfTPI>D4% zH|N4P@ezhml5B#hknUGp*ddDdB3{f-QskCCLK$PQXSLmnBANAY;U%HHEC?BpU?#oV zxKXCzpr8eGh~On*yg~^X&|s9k+ayt@A43TW=upB-!g>`GGGM_>dbhnrnMQ!}5zt|T zmwe4n zecL{wOrt^xzSemIFZtMumWY82X41DU1!Wo?%ICF?9K7TcFDW7hDi~$Iwjz{iOsLM+ zI%@Ee2woOM40JG)er-)C)7VhZf;zhJl89cRL<~$Y%KmKwDATwvxdaW21NRUkQ;8~= zg&h2QhEQhy6T&Z6KOR9DuPq83(G&fsMY(2}1qk>t{!M|@vb_SPEqTIUu}sF zda~oED$z?HuU*hQ1Rnpl8(YrI;^6;v=-V{zGcG}WW0O6^(A13o=|TXt%H?Nj&}^*7 zv3RS?XYH1#)C~7dSQ-xER$tQp8Vx%K|DmC8Gq{{wg2u*Mdx)v28I{cc-w8W)jB)l5 zeNuJHm=PWP+lLsYaUXI0|0ndFITY;N27>g;U6~gL>4z+_k{v^)LUwoeN7gSpcUsoi z&hagQ-?f z6P6z5A2z$E0232H8;{1i!ur_(K3bK-uvevVDx2NOeA+yF+qq{O@mI~wKj1-(cyf{x=ge1W<8BtL9!h$&l0^Qr8>jAcF zLr@C2137`nJCZeSXb}-Lo+Pb~&pa0AZLfv0tSkIUXfQsNyjo8r`?yVpn1w(?L?(u# zX9q{*{mkTRTQPBT)X*ia{xf&_=LOV2l3<#-$F~;z)l6zxq(4M9+h)5)_-ghW{afqDUJ-%9fg6aW%fwhm zMu6pK#RkF5h%sE~)K&iZFB6y^pH*EWB)oW?x*~^dCH6_b93~m z`j=styw~5{y-1WqLP}>Exja|JniJcaJMdpcYS$$De%h9rba`{t@!+i;(o2 zDix9W5$Vt+;ewH*h6yKw;f;=%9<4ht$Y%=Ah>U>QFE7AcCPrBi^_CsRG7w z%SA^$lJ~J?}Wqfc@I3eJp$^pGfe`(7GldsW0GuhQ+EOVE`qrrRj<^|FUqQ;^eGsDvsB zh=^)GrsKMJ6Um!B0?j^(ej64ed(%M)`!O&gGzoS2>xQM$o~Fu~+;ivs?QTR`c|{cr z08{Bw!~8eK$&5sxiecy*CrMy@Uc)fg8QowIR7FBl*Fs4TUXjsi(e+3po$9QNO3)NG zjL)o1%B*?Htm1y%l22QLC2vMDSK^?Gvd>LP^+(6g2WK~JskF(Skm2UZh7Kzw_^WR9 z;34Oq-_*jmuQXc|T^mCK1v?wa*9ReAbt~4`mDZTS^zd%!#A-D$Bye*hfBCT>vLLJ= z^5tjH3%M0(S#-g2=Kbr{C|}7p=b(=A?3&dxDwgH{(b&)jwEtIAZHc$Zy+9xG7{(JyFGcsRPR$ar3Ba`R<~ zuh*~a*EfcAb({qeJoy|{YUIhPKoyJ0txltvUZ+4uubG@gHMmImJ(=1$M8fZ76Cl28 z-PP9chy6uk{^+QU?t#O$bcSq;qa#(Km_hErAT0*@`1J}%@8VL34!Q6B)b$MQQ5 z(%3$kfuR^IBjdixu6kG4v*g75lf40qmX6XP`WVkLZCcDYUFKS>IA6E}I*j1;b`>jZ zaX>$U1bE;7l15jTO@;xjY=iACZ&4;$JT`WJYutiX5_RlNOP!mB)X*?(thj_NV6xlt z_G0oJ!M3*V9Lz|QH4*3fK@#v8?_(^uxsehwqq7X&Z{6YqFuM);^!6H4b{gB7?l~Im zNhJmChD%-rmfQV0v`8{(?Z!jinC{PuIv2@(2!QBXul6srn3j=S{4}ZuOo3!Y&Yx;y z7ra>CA1i!5Um}RFYbrCKJ8WOGRgI@6{kPW|xqPeuN zJ=oSO+tn*!bok9MaziL^WKc&bF3Rx<$0H(od*N-1Z+e=r$j1Q`Ux%G$GW2TBL}7!u z+n#ooHnyXmmY0m=6t**~RaeCia0ugP^sqJf09QZBZGVZe;%Up2?-a7|VlpX2uX^R= zAg{uY{{zSUX_ylfu+Z-&=n9wmpcjUc9@*LmS66kQbCzD`vjn_raK6q&u>IIV1d7-Sg7*+saNX{Tom-V%Ks%h+FUtIzd_@0su7$1)ih z)WoWIpA;PBN9x%M2sR&&Hb*nWHkdo@xK2?+igy$OblB`Vk@z}>IFGcUKTf?DT`rR! z*I=f`pqF{y8dc69PeVz>G!9Shz#mz|yK&6`Bmf3hZKsgC+9MML>#CWeO)uBbUw#W* zb7&*u=Z&%27Iz9JdqL0JdFGH+=c}IAqiGz2SaBlHic{V}^oXKRYJHSc{ZX_4a#dmt zp^L)K7OmUE6Zncr6gIc1uhx7#CW&-98*4AI4<5XP+Y!#v@W|Z5bbw-|*fjj!k%qpw zL-<`9^;4uFj2s929-=%4AICV2+({JnNDIx28&5GyP2vX)uNwm8vQJyLExx~Tr$Fz^ zaDH(19CdCJSKKk$Le3^)pFM4mvtpOjE%8N%f@x_2@pF~JDx}ueG0(4&Q;3}=73MzM z37v12!Cw(0Ho?90%7|>7MN&>V9pwl}MT$g-@T%c3Gob16h1w4z$;)&PJNT zR4b6kPuq8gp{m;y50ezpi6cv4j6pg@$g`1U=eT+98lCUqm(WxGSbJN}iGBYuY||8# zjymEQR?(t$e0fSCbpcMa9?e8Ui=`esHHhFE)jYULW5cTQl3bR?j0Y+221QqpjKrhP zYzw<1sWJYvat0LvpKvjA@DxQ&2##c3$^8I^pP-1C}0k0HN(pe#&a z$(Ig`e%1w|Ff$~!SGlNs&wUZIb9Yf#EebsNKfT6KWO=f*vLYkF^MZ_q3 zGzblnVdl&^5gUCGuR-2n{NwLH3$#MOu1^uQIQ_0dWXS_)Z6hixkC?T@k?P_V;2l_Z z%w!#+d%ebv z8p80su^c6uihKOnLWE_UQoKW6AAyTRf)Edn$y?mhzwQ~)5~V$P*pn1ojps5NE_%<^ zbFiRSsdRmpl}D-Q3{X;F__*k7U-xpb*#>1yAiRD*YX~F`oEH^u&2m`MXP=C5!aMb# z=!rT?dTpFKZ{UI~*bHQUt|R(1^98GbCDq|K#9UdtFQJRRgN>u54;*R*7AOz7jQmo_ z!(?P#QQw-l)rsR?f4ssf`9S;JcL$v+M#&*NGYzZo&}I5D8mBnEbVG+tI6h}CKZZ>T zixhkJ*D}LBuUGY+kCiXG;h^5VJhg7`u%!b%@4~1;3S~k{IthX^82$Lv>5#MV3y;yE zT6;Xh`?#m(mEN>Y*F>s$ywfmNzKMG0LOuD)));fIo+Lkd`^+Shl|i{rtTcysrVne=a~;88suQI8e9I2`;OdV)!&Y7mDlC!nUoHS- zy1S&7dE(^!dUL7oXN*j1S0wI`!V@6Ix|OU2d%oyR{-`qPErgWsyzOlwL$heK>f=}a{0~5 zhWNJKYje;Q0`Zve8cVm)%OHde=U)$WappOaf!U_SbL| zx|%Rsj85}sHWp$WRqlAPG3w9fv2*pSl*_{O1K$r+F22aa$N6T4xGd!{snqv!1r3c6 z9e4ga&ag7l``SkVdfP&U#R^cCLb;J{fx5^>bwz#K&B%Jgy(`!mL7CRdh%48aBwsRA zw*~U_oorVgJ~?))A5uj8T;6#l|3r&d!B;|2grwOJxWl=*m7yAV$*=x_?UOTpIe>J{ zCof8>6J71Kryd~uXlctYMwpIf+u^NKj${}YCi(bC&nC0TMZkBMm9SWj+3&h~!Apqk z-kakODx(0Sc74W5cxD{Q1G-1o$>Bj5u`U3#r&i^f@n(V4`YtC5)*_au7a5T$6GasJ9`!$YDshO~>sU9i^{tFO%ar7XLdF;_;==#vxH_QhnP8@pkxkVy2lt_qx_ z>xtUMx9kk&`Psmk-9C}Q9_4*X_rs5{WxSehkzpO(In>lC7Rn|S@W^vJjRN{jc}`h; zZ?@v?7eF=SZeI(CpXOa?*@`*udkiBbPmteHB#v5fMeoYf9LYH*6 z-wCc!ujl)SixT3D8^*IPR>@l?vvsd(q?Z}Qcr;3${+`?$(!A1evnM~kO<<~>BtQvf zgzAUzV*op>6B0V|7gS%@2JNKae7}qgjH4Ore)dZ12xYyko@vNQ(P|zebT6FRFoh9~szrKBBsX47Ym)QAO#P{{`~jwn5;L@UaX+y>f-y72*z#WES?FGJ#l^@If@%y!t5{hmCHP) zp=*w`+ddejcZQ5IKI>_0eE7mk0lV(RT#}qD!|4w%D|Lt9g?n*Iw#4yV8q_Q=>-U_z_zvAzYwZe6tC^&|O&2714EmVUY?B<0OUbs6hKkNeWz zmmIm%DFR_F?}atGizM6>yUq+Ee#3Xx*QhP^cG3@LpZO^!E|*|`Vk6dkNv1~Z<{_5b zg!Azwv%q6wy{6Zw3s3OTtvE5Scs6OpYcXP@8R(K7Qyf;07Qc`AD1Sd1K_YvbD)K3T zhBn$p#!vaPl{hb+q~X+@t0VntI5Q@t;T#|M*J!=p7A@Q)fX@HD610@Y9tosb-r zhq3d0kBL-XbAG&8k+|x`xW;v>KBU(07HOs)bJT4ssd^QYf{noxERI@{!kHhk8L|e& ztCTKDAqLwuWv17+x%gm(Hb9m8FuqTt3(h9jNufO@OX(^iB&b6H)3BXY*snaO<**!X zT0C)1>8bnYmU^!bP?Y}U*1OZr@=fDQUCevRW+oafXX3eOjF|JJS-2p^v%}L7q;n1$ zKT8Y!8r%hAOVt!PC1$6bQkMf%$yo9AnOkVc#Mff0U9uKD=`Cgd_a?qie@=YuoXrjI zX1raha#phpC^z7nOOXV?NwSBtu}JdO6Y~4IsZ~s-rPA-)3iOS`wm-FcY~qMLfF|<8 zBGF49UUYZl)h^kemsHqjLP=C;zv|&Tj}UaC@sA0=+&U-ZZhS3W!a$%$#=f0}+dCq4 zlpkM+ODv=RItCh^`!K6qex`bN<2WIkoevv#xhx7kQ4dyu!v-LU#yxPB6H`w++-RpD z$-x9arO@iL`ZOixtNhwaxy^Dh%}5d!vScyM+T9f=uNcb?)vat*UYQE!4})cysD_Gz zPn0RPNWBZc2X*;U2L`jn!!%z{IJYATamrhM3xP!+mD7RNTYdMqo05_9JYhsko|Z;+ zpo?<-wOPhH!a=JwOX&Elyzq_-;wybTHPkl^L_Y6hikM**^UdMx@pUcvMp7{ zhB%xbU5=QWfW;tLZ315$#uaYeG`w~J-b7l=;b%H5eESY#`sxD5G{+6+r^alG$2||D zm}&-oAQ-?1AeZ>Rj0#||-dJs?f%bpi=jBCt3N&Hnd{zh-WPDb;<~@?J!PuZZ=mnfG z6zvq6!}|VY1Ftx?mTtD@z0kYGgANBL<8oy>)nZI!_D-=LqL`B%%4d;{I50dSXs`XuFyWgX`7pk>1*hW;B***@nyA5ays1C*kn} zRKuEGg*KtZ@)8(;*#b8t>vJfda)R{jT9B{c)gPsLh7iR>Uqr}^GH6P^k1L;{9e%FLQWF!Ay*Q|Q`oqoMS~4~!_#L!yn&ET_vO zbL+Vvw|+%9rl-cmEH$}xNjk6mW{NN7AeA9I?)0H;Ewet+Hgyl{91e`o!HIE(+~w*V zs63$6KI+KoTP2g38g6yrN6vXNBCLst?l;d+aj{a@N@RDbe3BY$I+j}e_>IZE`ZO2( zC+N!QkQ|Jxa!TRmcGTu}X4bik-gKsHxv^VokeY6N1Yz**l(dW_lEr`4rQ~kAiAS#S zoXAs6*~*UtuWa^Yb+F8C?CEXPVa zQPNUwwN_tk$xS`?PceC&nEAb^n5F&$M@k&1SdI3!6zdueQkTz+(ik+sH>6U)cX$8! z4Jpj~x_obFV{2t;OMicc+>v_mkr4^}9IFS~Z3Hh5$7Avfk{tB)eBp$GBwn&QC~4$f zr**hAH0sXot}IlUq5{9Y!hZinPJTH)U~|>spe-L(S{IcJnLs|_>sVQBYL^Gk>t$Ct z+wOqsTYcmi^L(|lKJIw2yj>9@Q?iPNrL$27lZya~7s{lsV4t@_ zn6Q{Yrv!lQ%mDxVb0*05Od$Kk^sKDj-911miU<5GE*yWEZ(KqP?Jy6$1KZ@4^zu2Y z;24Im%L4a%Ua^=5mXmMI;>l*y<#|)HvlU!~d_oN6yf}Bgp7v-2eI#E%j4_h;?J1p% zfb;GP43Z1fN+Y1MSThwA5<;US6tex6x7fJbBF>5{&T;&<`5VzQyy!HYx+bQJ` zeix^~B>(z!0)AKVVbCc~+Y!vvyK1Zr-^X?&Fa?N$Um3IB({IQ4Fo*_v`{EdvcGFB4 zKO44upCU(E{ZT4b+~Xw&bTCoYdlWP6$JXj!1JPL%b1(xWQ@bKa2L_O}V2!Xc^U>Oa zjF$t)E%MTrEYZu!+FRIzv}@%F9ve z6=3%JQrD$jg+`fN>-H4JLI1~uGAIZBzu%fb_Y9VGR=E94E2j7|LN9Fb#g9AMgZ3mAk>8q z622GtjUT+hTm5{%$@tI1_v}7=_xl`w58~bWOLMf8bSDFfpG@0}q8W7ZhfiMHHwcK$ zzvlR`F6l8otv|CtUfYc_o0BJ`DO6O%HraG;%B@wxYuCN-?IP~u2bwot8FGp>Iao&( z&P`3w8$K+b_j*2kz~?Cn4p~QcVETf;ST$K`qSy3+490c1UgfFTHhP80ij=T)Kg#FA z`dBmScWcf`bai;H{MKu|lb;{d(lP5KFRXAZC@K|qz{!_BlVV407u@SIq0kH!Xsdl5 ztcEuDA>F8F)8HuMa0T%k#V0MeHHoz)5~+W4X$3l*^6NTf!Y-eVUEy=*g4da1x9`7P z@5LE!)5GA^h7J(9ervo`{4tmk?L=OC_Ee8OVoyTbHn@9DndSzUY_a_)t5wAf3gTNn zWWQ$^VBdEByS_yL`}W?kKNqtEkh~dCV84O4jT!;v-|K$NC}Siue~SO9Mv#a<*R-%> ze_uWnAwK#7;E_`l@2rP>s!K@ysmDgg1arIn*~2Md6VC$#Idxj{3JRUB2lmyJ^z6L} zmaqBh+R*fTKJbb-u5st#8{;zBn2hBR^Ytf)Sk*g8Eu78p4_U<;Ix5*&$h}StN;_C z0Mq^FX#`oy(h#(=v@^7{(^hnTZ)mFt840=+CBP#A3Xi}cinB)7BP=B)RMjfO8D%P- z4?*N49emD+zH^Qz8E6zC9{lqL=B>v~Y|O`!o7E$|NKnc{M&<*eG!K)xlxFO|^2UXn zrjg=&R+8sJz~y>Saj|FA!0puLOQ$iQiksz$0tG<(D0INB`uYj>$B$J{-z^)qM7~3b z9@&y!8xkpF43$EBmCP$ZRLe;gKJSdqsXywfxy}?tC(y2X@)mu%*BrQlckZB^8uUg} zPLYEm-s-95vZa!FWRn0bMbG0P1xBpa&)b0YhjzO@hU>>bZsgbHx=x=T`HHt|GYJsz zQ_(!k9C17B$kB96v10myZAy`sSN#J@uF7cO%KN4=toO`LhY~vP9A~TbD-kU@Qv7&^ z_jiQ>q9Oc_)s;;59}f?acP$zGIN=6)LZ2E3;BdmYx^~rk_3>Z!sOce1B!0?If2KMK{37$59gZ6J$#Qu)(`^Oo`Wa7_*S@a&^kEzAqRe%5d0QtQ0=g}{I zr}}R{{rnx`_iGsBgT|jnti&C}?^mq9EB`)7LoRcF9*REb|YCOIZonM-X%*@V^G|rA0sv^6Gy9y;w4) literal 0 HcmV?d00001 From f551c54f6eba71ca49d8a5c427e3ef7049ecc6e8 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 21:45:38 +0300 Subject: [PATCH 29/31] =?UTF-8?q?=D0=BC=D0=BC=D0=BC=D0=BC=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Objects.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/Objects.tsx b/frontend/src/pages/Objects.tsx index 9d1c4e2..b9aaa9c 100644 --- a/frontend/src/pages/Objects.tsx +++ b/frontend/src/pages/Objects.tsx @@ -68,7 +68,6 @@ export function ObjectsPage({ mode }: Props) { const [importStep, setImportStep] = useState<'upload' | 'preview'>('upload') const previewMutation = usePreviewObjectsXLSX() const importMutation = useImportObjectsXLSX() - const fileInputRef = useRef(null) const objectUrl = (id: number) => mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}` From f2407f9f7b5d0fa8e65e5069fecdd265978d95f7 Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 21:48:40 +0300 Subject: [PATCH 30/31] =?UTF-8?q?=D0=BC=D0=BC=D0=BC=D0=BC=D0=BC=D0=BC?= =?UTF-8?q?=D0=BC=D0=BC=D0=BC=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/objects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index 3b12aaf..7e19be8 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -296,7 +296,7 @@ async def _parse_objects_xlsx( def _cell(row: tuple, key: str) -> str | None: idx = col.get(key) - if idx is None: + if idx is None or idx >= len(row): return None val = row[idx] if val is None: From 5cda39515388088ad2bdb5c312f98f1624510f8a Mon Sep 17 00:00:00 2001 From: dv Date: Wed, 8 Apr 2026 21:56:11 +0300 Subject: [PATCH 31/31] =?UTF-8?q?=D0=B0=D0=B0=D0=B0=D0=B0=D1=8D=D1=8D?= =?UTF-8?q?=D1=8D=D1=8B=D1=8B=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/Objects.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Objects.tsx b/frontend/src/pages/Objects.tsx index b9aaa9c..67c66d4 100644 --- a/frontend/src/pages/Objects.tsx +++ b/frontend/src/pages/Objects.tsx @@ -200,7 +200,7 @@ export function ObjectsPage({ mode }: Props) { >