diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ea06cf3..6259af3 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,11 @@ "permissions": { "allow": [ "Bash(xargs grep:*)", - "Bash(grep -E \"\\\\.py$\")" + "Bash(grep -E \"\\\\.py$\")", + "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\")" ] } } diff --git a/backend/alembic/versions/0008_inventory.py b/backend/alembic/versions/0008_inventory.py new file mode 100644 index 0000000..745c56f --- /dev/null +++ b/backend/alembic/versions/0008_inventory.py @@ -0,0 +1,34 @@ +"""0008 inventory fields + +Adds location, connection_params, vendor columns to devices. + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-04-07 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB + +revision = "0008" +down_revision = "0007" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("devices", sa.Column("vendor", sa.String(255), nullable=True)) + op.add_column("devices", sa.Column("location", sa.String(20), nullable=True)) + op.add_column( + "devices", + sa.Column("connection_params", JSONB, nullable=False, server_default="{}"), + ) + op.create_index("ix_devices_location", "devices", ["location"]) + + +def downgrade() -> None: + op.drop_index("ix_devices_location", table_name="devices") + op.drop_column("devices", "connection_params") + op.drop_column("devices", "location") + op.drop_column("devices", "vendor") diff --git a/backend/alembic/versions/0009_object_city_client.py b/backend/alembic/versions/0009_object_city_client.py new file mode 100644 index 0000000..051fff8 --- /dev/null +++ b/backend/alembic/versions/0009_object_city_client.py @@ -0,0 +1,28 @@ +"""0009 object city and client fields + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-04-07 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0009" +down_revision = "0008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("objects", sa.Column("city", sa.String(100), nullable=True)) + op.add_column("objects", sa.Column("client", sa.String(255), nullable=True)) + op.create_index("ix_objects_city", "objects", ["city"]) + op.create_index("ix_objects_client", "objects", ["client"]) + + +def downgrade() -> None: + op.drop_index("ix_objects_client", table_name="objects") + op.drop_index("ix_objects_city", table_name="objects") + op.drop_column("objects", "client") + op.drop_column("objects", "city") diff --git a/backend/app/main.py b/backend/app/main.py index e833f85..b24462f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,7 @@ from fastapi.openapi.utils import get_openapi from app.middleware.auth import AuthMiddleware from app.plugins.loader import load_plugins -from app.routers import admin, alerts, auth, devices, events, objects, racks, snapshots, tags, tasks, zones +from app.routers import admin, alerts, auth, devices, events, global_devices, objects, racks, snapshots, tags, tasks, zones from app.workers.monitor import start_monitor, stop_monitor from app.workers.startup import recover_stale_tasks @@ -41,6 +41,7 @@ def create_app() -> FastAPI: app.include_router(auth.router) app.include_router(objects.router) + app.include_router(global_devices.router) app.include_router(devices.router) app.include_router(racks.router) app.include_router(zones.router) diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 576092d..e5bcfbd 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -7,8 +7,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from .base import Base, TimestampMixin -DEVICE_CATEGORIES = ("raspberry", "mikrotik", "camera", "embedded", "server", "other") -DEVICE_ROLES = ("entry", "exit", "server", "nvr", "switch", "other") +DEVICE_CATEGORIES = ( + "main_server", # caps-cs — основной сервер объекта + "vm", # виртуалки: БД, Asterisk, RTSP, recognition + "router", # MikroTik, IRZ и прочие роутеры + "embedded", # пром-ПК стоек (Raspberry Pi) + "camera", # камеры (role: plate / face / overview) + "io_board", # модули ввода/вывода (Овен МК210-302) + "bank_terminal", # банковские терминалы (SberPilot, PAX IM20) + "cash_register", # ККТ (PayOnline-01-ФА) + "other", # всё остальное: RFID, табло, реле, IP-телефоны +) + +# Используется только для camera (остальным выставляется "other" или NULL) +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") @@ -31,6 +47,7 @@ class Device(Base, TimestampMixin): # Classification category: Mapped[str] = mapped_column(String(20), nullable=False, default="other") role: Mapped[str] = mapped_column(String(20), nullable=False, default="other") + vendor: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) model: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) firmware_version: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) @@ -64,6 +81,18 @@ class Device(Base, TimestampMixin): index=True, ) + # Для устройств не в стойке: "server_room" | "street" | None + # rack_id и location взаимоисключают друг друга + location: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True) + + # Параметры подключения для операционной вебки (Вариант В) + # camera: { auth_type, username, password, frame_url, stream_url } + # bank_terminal: { port, timeout_on_sale } + # cash_register: { port, tax, taxation } + # embedded: { config_path } + # io_board: { port, protocol } + connection_params: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/models/object.py b/backend/app/models/object.py index 5001b16..a5dbd11 100644 --- a/backend/app/models/object.py +++ b/backend/app/models/object.py @@ -40,6 +40,9 @@ class Object(Base, TimestampMixin): ssh_pass_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) ssh_port: Mapped[int] = mapped_column(Integer, nullable=False, default=22) + city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True) + client: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True) + notes: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/backend/app/routers/global_devices.py b/backend/app/routers/global_devices.py new file mode 100644 index 0000000..f2fc6f0 --- /dev/null +++ b/backend/app/routers/global_devices.py @@ -0,0 +1,58 @@ +"""Global devices endpoint — cross-object device listing with filters.""" + +from typing import Optional + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.dependencies import get_current_user, get_db +from app.models.device import Device +from app.models.object import Object +from app.models.user import User +from app.schemas.device import DeviceRead + +router = APIRouter(prefix="/api/v1/devices", tags=["devices-global"]) + + +@router.get("", response_model=list[DeviceRead]) +async def list_all_devices( + category: Optional[list[str]] = None, + city: Optional[str] = None, + client: Optional[str] = None, + object_id: Optional[int] = None, + status: Optional[str] = None, + is_active: Optional[bool] = None, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """List devices across all objects with optional filters. + + Typical use cases: + - All main_server devices: ?category=main_server + - All bank terminals for a client: ?category=bank_terminal&client=Авангард + - All devices in a city: ?city=Санкт-Петербург + """ + query = ( + select(Device) + .join(Object, Device.object_id == Object.id) + .options(selectinload(Device.rack)) + .order_by(Object.city, Object.name, Device.category, Device.ip) + ) + + if category: + query = query.where(Device.category.in_(category)) + if city is not None: + query = query.where(Object.city == city) + if client is not None: + query = query.where(Object.client == client) + if object_id is not None: + query = query.where(Device.object_id == object_id) + if status is not None: + query = query.where(Device.status == status) + if is_active is not None: + query = query.where(Device.is_active == is_active) + + result = await db.execute(query) + return result.scalars().all() diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py index a011552..10c6688 100644 --- a/backend/app/routers/objects.py +++ b/backend/app/routers/objects.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from sqlalchemy import select, distinct from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -9,8 +9,9 @@ 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, ObjectRead, ObjectUpdate, SyncResult +from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult from app.services import crypto +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 @@ -34,10 +35,30 @@ async def _load_object(db: AsyncSession, obj_id: int) -> Object: return obj +@router.get("/meta", response_model=ObjectMetaResponse) +async def get_objects_meta( + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Return distinct city and client values for autocomplete.""" + cities_result = await db.execute( + select(distinct(Object.city)).where(Object.city.isnot(None)).order_by(Object.city) + ) + clients_result = await db.execute( + select(distinct(Object.client)).where(Object.client.isnot(None)).order_by(Object.client) + ) + return ObjectMetaResponse( + cities=[r for (r,) in cities_result.all()], + clients=[r for (r,) in clients_result.all()], + ) + + @router.get("", response_model=list[ObjectRead]) async def list_objects( is_active: bool | None = None, tag_id: int | None = None, + city: str | None = None, + client: str | None = None, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): @@ -46,7 +67,11 @@ async def list_objects( query = query.where(Object.is_active == is_active) if tag_id is not None: query = query.where(Object.tags.any(Tag.id == tag_id)) - query = query.order_by(Object.name) + if city is not None: + query = query.where(Object.city == city) + if client is not None: + query = query.where(Object.client == client) + query = query.order_by(Object.city, Object.client, Object.name) result = await db.execute(query) return result.scalars().all() @@ -140,6 +165,23 @@ async def sync_devices( ) +@router.post("/{obj_id}/discover", response_model=SyncResult) +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) + return SyncResult( + created=result.created, + updated=result.updated, + skipped=result.skipped, + errors=result.errors, + ) + + @router.post("/{obj_id}/sync/sheets", response_model=SyncResult) async def sync_devices_from_sheets( obj_id: int, diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py index 1d5cbaa..fd1042c 100644 --- a/backend/app/schemas/object.py +++ b/backend/app/schemas/object.py @@ -10,6 +10,8 @@ class ObjectCreate(BaseModel): name: str description: Optional[str] = None server_ip: Optional[str] = None + city: Optional[str] = None + client: Optional[str] = None # Object DB sync settings db_host: Optional[str] = None db_port: int = 5432 @@ -33,6 +35,8 @@ class ObjectUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None server_ip: Optional[str] = None + city: Optional[str] = None + client: Optional[str] = None db_host: Optional[str] = None db_port: Optional[int] = None db_name: Optional[str] = None @@ -55,6 +59,8 @@ class ObjectRead(BaseModel): name: str description: Optional[str] = None server_ip: Optional[str] = None + city: Optional[str] = None + client: Optional[str] = None db_host: Optional[str] = None db_port: int db_name: Optional[str] = None @@ -74,6 +80,11 @@ class ObjectRead(BaseModel): model_config = {"from_attributes": True} +class ObjectMetaResponse(BaseModel): + cities: list[str] + clients: list[str] + + class SyncResult(BaseModel): created: int updated: int diff --git a/backend/app/services/device_discovery.py b/backend/app/services/device_discovery.py new file mode 100644 index 0000000..77dc051 --- /dev/null +++ b/backend/app/services/device_discovery.py @@ -0,0 +1,299 @@ +""" +SSH-based device discovery: connects to each rack PC, reads its caps config, +extracts plugin URIs and classifies discovered network devices. + +Discovery chain: + Object DB → racks table (host IPs) → SSH per rack → parse config → upsert devices + +Limitations (devices not discoverable automatically): + - Serial/USB devices (no network URI) + - RODOS-8 relays, Moxa switches (not represented as plugins) + - Physical MikroTik router (not in rack configs) +""" + +import re +from configparser import RawConfigParser +from dataclasses import dataclass, field +from io import StringIO +from urllib.parse import urlparse + +import asyncssh +import psycopg +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.device import Device +from app.models.object import Object +from app.services.crypto import decrypt + +# ── URI extraction ──────────────────────────────────────────────────────────── + +_SERIAL_SCHEMES = {"serial", "com"} +_SERVICE_HOSTS = {"localhost", "127.0.0.1"} + +def _extract_ip(uri: str) -> str | None: + """Return IP from a URI string, or None if not a network device.""" + if not uri: + return None + try: + parsed = urlparse(uri) + scheme = (parsed.scheme or "").lower() + if scheme in _SERIAL_SCHEMES: + return None + hostname = parsed.hostname + if not hostname or hostname in _SERVICE_HOSTS: + return None + # Validate it looks like an IP (simple check) + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname): + return hostname + except Exception: + pass + return None + + +# ── Plugin class → device category ─────────────────────────────────────────── + +def _cls_to_category(cls: str) -> str: + cls_lower = cls.lower() + if "camera" in cls_lower or "facedetect" in cls_lower: + return "camera" + if "controller" in cls_lower or "ovenmk" in cls_lower or "rodos" in cls_lower: + return "io_board" + if "payment" in cls_lower or "sberpilot" in cls_lower or "payx" in cls_lower: + return "bank_terminal" + if "payonline" in cls_lower or "kkt" in cls_lower or "fiscal" in cls_lower: + return "cash_register" + return "other" + + +def _cls_to_role(cls: str, plugin_name: str) -> str: + cls_lower = cls.lower() + name_lower = plugin_name.lower() + if "camera" in cls_lower or "camera" in name_lower: + if "grz" in name_lower or "plate" in name_lower or "lpr" in name_lower or "frame" in name_lower: + return "plate" + if "face" in name_lower or "lico" in name_lower: + return "face" + return "overview" + return "other" + + +# Plugins whose URIs point at the CAPS server itself, not at separate devices +_SKIP_PLUGIN_PREFIXES = ("mnemo", "processor", "recognition", "photo", "voice") + + +# ── Config parsing ──────────────────────────────────────────────────────────── + +@dataclass +class DiscoveredDevice: + ip: str + category: str + role: str + plugin_name: str + cls: str + + +def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]: + """Extract discovered network devices from a caps config file.""" + parser = RawConfigParser() + parser.read_string(config_text) + + discovered: list[DiscoveredDevice] = [] + seen_ips: set[str] = set() + + for section in parser.sections(): + if not section.startswith("plugin:"): + continue + + plugin_name = section[len("plugin:"):] + if any(plugin_name.startswith(prefix) for prefix in _SKIP_PLUGIN_PREFIXES): + continue + + cls = parser.get(section, "cls", fallback="") + if not cls: + continue + + # Collect all URI-like values in this section + uri_fields = ("uri", "frame_uri", "host") + for field_name in uri_fields: + raw = parser.get(section, field_name, fallback=None) + if not raw: + continue + ip = _extract_ip(raw) + if not ip or ip == rack_host or ip in seen_ips: + continue + seen_ips.add(ip) + discovered.append(DiscoveredDevice( + ip=ip, + category=_cls_to_category(cls), + role=_cls_to_role(cls, plugin_name), + plugin_name=plugin_name, + cls=cls, + )) + + return discovered + + +# ── SSH operations ──────────────────────────────────────────────────────────── + +async def _read_remote_config( + host: str, + port: int, + username: str, + password: str, + config_path: str, +) -> str: + async with asyncssh.connect( + host, + port=port, + username=username, + password=password, + known_hosts=None, + connect_timeout=10, + ) as conn: + result = await conn.run(f"cat {config_path}", check=True) + return result.stdout + + +async def _get_rack_hosts(obj: Object, db_password: str) -> list[tuple[str, str]]: + """Return list of (external_id, host_ip) from the object's racks table.""" + if obj.db_via_tunnel and obj.server_ip and obj.ssh_user and obj.ssh_pass_enc: + ssh_password = decrypt(obj.ssh_pass_enc) + async with asyncssh.connect( + obj.server_ip, + port=obj.ssh_port or 22, + username=obj.ssh_user, + password=ssh_password, + known_hosts=None, + ) as tunnel: + listener = await tunnel.forward_local_port( + "127.0.0.1", 0, obj.db_host, obj.db_port or 5432 + ) + local_port = listener.get_port() + dsn = ( + f"host=127.0.0.1 port={local_port} dbname={obj.db_name} " + f"user={obj.db_user} password={db_password} connect_timeout=10" + ) + return await _fetch_rack_hosts(dsn, obj.db_table) + else: + dsn = ( + f"host={obj.db_host} port={obj.db_port or 5432} dbname={obj.db_name} " + f"user={obj.db_user} password={db_password} connect_timeout=10" + ) + return await _fetch_rack_hosts(dsn, obj.db_table) + + +async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str]]: + async with await psycopg.AsyncConnection.connect(dsn) as conn: + cursor = await conn.execute( + f"SELECT id, data->>'host' AS host FROM \"{table}\" WHERE data->>'host' IS NOT NULL" + ) + rows = await cursor.fetchall() + return [(str(r[0]), str(r[1])) for r in rows if r[1]] + + +# ── Main entry point ────────────────────────────────────────────────────────── + +@dataclass +class DiscoveryResult: + created: int = 0 + updated: int = 0 + skipped: int = 0 + errors: list[str] = field(default_factory=list) + + +async def discover_via_ssh( + db: AsyncSession, + obj: Object, + config_path: str = "/etc/caps/caps.conf", +) -> DiscoveryResult: + result = DiscoveryResult() + + if not all([obj.db_host, obj.db_name, obj.db_user, obj.db_pass_enc, obj.db_table]): + result.errors.append("Object DB connection is not fully configured") + return result + if not obj.ssh_user or not obj.ssh_pass_enc: + result.errors.append("SSH credentials not configured on this object") + return result + + try: + db_password = decrypt(obj.db_pass_enc) + ssh_password = decrypt(obj.ssh_pass_enc) + except Exception: + result.errors.append("Failed to decrypt stored credentials") + return result + + # Step 1: get rack hosts from object's DB + try: + rack_hosts = await _get_rack_hosts(obj, db_password) + except Exception as exc: + result.errors.append(f"Cannot fetch rack hosts from object DB: {exc}") + return result + + if not rack_hosts: + result.errors.append("No racks with host IPs found in object DB") + return result + + # Step 2: SSH into each rack, parse config, upsert devices + for external_id, rack_host in rack_hosts: + # Resolve config_path override from the embedded device's connection_params + existing_rack_pc = (await db.execute( + select(Device).where( + Device.object_id == obj.id, + Device.ip == rack_host, + ) + )).scalar_one_or_none() + + effective_config_path = config_path + if existing_rack_pc and existing_rack_pc.connection_params: + effective_config_path = existing_rack_pc.connection_params.get( + "config_path", config_path + ) + + try: + config_text = await _read_remote_config( + host=rack_host, + port=obj.ssh_port or 22, + username=obj.ssh_user, + password=ssh_password, + config_path=effective_config_path, + ) + except Exception as exc: + result.errors.append(f"SSH to {rack_host} failed: {exc}") + continue + + devices_found = _parse_config(config_text, rack_host) + + for found in devices_found: + try: + existing = (await db.execute( + select(Device).where( + Device.object_id == obj.id, + Device.ip == found.ip, + ) + )).scalar_one_or_none() + + if existing is not None: + changed = False + if existing.category != found.category and existing.source == "auto_ssh": + existing.category = found.category + changed = True + if existing.role == "other" and found.role != "other": + existing.role = found.role + changed = True + result.updated += 1 if changed else 0 + result.skipped += 0 if changed else 1 + else: + db.add(Device( + object_id=obj.id, + ip=found.ip, + category=found.category, + role=found.role, + source="auto_ssh", + device_meta={"plugin": found.plugin_name, "cls": found.cls}, + )) + result.created += 1 + except Exception as exc: + result.errors.append(f"Error upserting {found.ip}: {exc}") + + return result diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6b45927..d140032 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,11 +3,13 @@ import { AppLayout } from './components/AppLayout' import { AdminPage } from './pages/Admin' import { AlertsPage } from './pages/Alerts' import { LoginPage } from './pages/Login' -import { ObjectDetailPage } from './pages/ObjectDetail' import { ObjectsPage } from './pages/Objects' import { SnapshotsPage } from './pages/Snapshots' import { TaskDetailPage } from './pages/TaskDetail' import { TasksPage } from './pages/Tasks' +import { InventoryObjectDetailPage } from './pages/InventoryObjectDetail' +import { WorkObjectDetailPage } from './pages/WorkObjectDetail' +import { WorkDevicesPage } from './pages/WorkDevices' import { useAuthStore } from './store/auth' function RequireAuth({ children }: { children: React.ReactNode }) { @@ -27,14 +29,27 @@ export default function App() { } > - } /> - } /> + } /> + + {/* Инвентарь */} + } /> + } /> + } /> + + {/* Работа */} + } /> + } /> + } /> + + {/* Общие */} } /> - } /> - } /> - } /> } /> } /> + } /> + + {/* Старые URL — редирект для совместимости */} + } /> + } /> ) diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index b2ac56f..dfd2666 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -8,11 +8,18 @@ export interface ObjectItem { server_ip: string | null ssh_user: string | null db_host: string | null + city: string | null + client: string | null is_active: boolean tags: { id: number; name: string; color: string }[] created_at: string } +export interface ObjectMeta { + cities: string[] + clients: string[] +} + export interface DeviceItem { id: number object_id: number @@ -80,6 +87,8 @@ export interface ObjectCreatePayload { name: string description?: string server_ip?: string + city?: string + client?: string ssh_user?: string ssh_password?: string ssh_port?: number @@ -195,11 +204,45 @@ 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 SheetsSyncPayload { spreadsheet_id: string sheet_name?: string } +export interface AllDevicesParams { + category?: string[] + city?: string + client?: string + object_id?: number + status?: string + is_active?: boolean +} + +export const useAllDevices = (params: AllDevicesParams = {}) => + useQuery({ + queryKey: ['all-devices', params], + queryFn: () => + api + .get('/api/v1/devices', { params }) + .then((r) => r.data), + }) + +export const useObjectsMeta = () => + useQuery({ + queryKey: ['objects-meta'], + queryFn: () => api.get('/api/v1/objects/meta').then((r) => r.data), + staleTime: 30_000, + }) + export const useSyncSheets = (objId: number) => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 578a1a3..2aeb344 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -1,6 +1,8 @@ import { - ApartmentOutlined, + AppstoreOutlined, BellOutlined, + ControlOutlined, + DatabaseOutlined, LogoutOutlined, SettingOutlined, ThunderboltOutlined, @@ -21,7 +23,6 @@ export function AppLayout() { const qc = useQueryClient() const { username, role, refreshToken, clearTokens } = useAuthStore() - // Open alert count for sidebar badge — refresh every 60s const { data: openAlerts } = useAlerts({ status: 'open', limit: 500, @@ -29,23 +30,27 @@ export function AppLayout() { }) const openCount = openAlerts?.length ?? 0 - // Global monitor SSE — invalidates caches when monitor events arrive useMonitorSSE({ onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }), onDeviceStatus: (ev) => { - // Invalidate devices for the affected object so status badge updates live qc.invalidateQueries({ queryKey: ['devices', ev.object_id] }) }, }) - const selectedKey = location.pathname.startsWith('/tasks') + const selectedKey = location.pathname.startsWith('/inventory/objects') + ? 'inventory-objects' + : location.pathname.startsWith('/work/devices') + ? 'work-devices' + : location.pathname.startsWith('/work/objects') + ? 'work-objects' + : location.pathname.startsWith('/tasks') ? 'tasks' : location.pathname.startsWith('/alerts') ? 'alerts' : location.pathname.startsWith('/admin') ? 'admin' - : 'objects' + : '' const handleLogout = async () => { try { @@ -70,11 +75,38 @@ export function AppLayout() { selectedKeys={[selectedKey]} items={[ { - key: 'objects', - icon: , - label: 'Объекты', - onClick: () => navigate('/objects'), + key: 'inventory-group', + type: 'group', + label: 'Инвентарь', + children: [ + { + key: 'inventory-objects', + icon: , + label: 'Объекты', + onClick: () => navigate('/inventory/objects'), + }, + ], }, + { + key: 'work-group', + type: 'group', + label: 'Работа', + children: [ + { + key: 'work-objects', + icon: , + label: 'По объектам', + onClick: () => navigate('/work/objects'), + }, + { + key: 'work-devices', + icon: , + label: 'Все устройства', + onClick: () => navigate('/work/devices'), + }, + ], + }, + { type: 'divider' }, { key: 'alerts', icon: , diff --git a/frontend/src/pages/InventoryObjectDetail.tsx b/frontend/src/pages/InventoryObjectDetail.tsx new file mode 100644 index 0000000..2691f35 --- /dev/null +++ b/frontend/src/pages/InventoryObjectDetail.tsx @@ -0,0 +1,718 @@ +import { + ApiOutlined, + DeleteOutlined, + EditOutlined, + FileTextOutlined, + GoogleOutlined, + PlusOutlined, + SyncOutlined, + ThunderboltOutlined, + UploadOutlined, +} from '@ant-design/icons' +import { + Alert, + Breadcrumb, + Button, + Checkbox, + Col, + Descriptions, + Divider, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Row, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, + Typography, + Upload, + message, +} from 'antd' +import type { UploadFile } from 'antd' +import { useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { + useCreateDevice, + useDeleteDevice, + useDiscoverObject, + useUpdateDevice, + useDevices, + useImportCSV, + useObject, + usePreviewCSV, + useSyncObject, + useSyncSheets, + type DeviceImportPreview, + type DeviceItem, +} from '../api/objects' +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 { stripEmpty } from '../utils/form' +import dayjs from 'dayjs' + +const DEVICE_CATEGORIES = [ + { value: 'main_server', label: 'Главный сервер' }, + { value: 'vm', label: 'Виртуалка / сервис' }, + { value: 'router', label: 'Роутер / шлюз' }, + { value: 'embedded', label: 'Пром-ПК (embedded)' }, + { value: 'camera', label: 'Камера' }, + { value: 'io_board', label: 'Модуль ввода/вывода' }, + { value: 'bank_terminal', label: 'Банковский терминал' }, + { value: 'cash_register', label: 'ККТ' }, + { value: 'other', label: 'Другое' }, +] + +const DEVICE_ROLES = [ + { value: 'plate', label: 'ГРЗ (номерная)' }, + { value: 'face', label: 'Лицевая / обзорная' }, + { value: 'overview', label: 'Обзорная (улица)' }, + { value: 'other', label: 'Другое' }, +] + +const DEVICE_LOCATIONS = [ + { value: 'server_room', label: 'Серверная' }, + { value: 'street', label: 'Улица' }, +] + +export function InventoryObjectDetailPage() { + const { id } = useParams<{ id: string }>() + const objId = Number(id) + const navigate = useNavigate() + + const { data: obj, isLoading: objLoading } = useObject(objId) + const { data: devices, isLoading: devLoading } = useDevices(objId) + const { data: racks } = useRacks(objId) + const { data: zones } = useZones(objId) + const syncMutation = useSyncObject(objId) + const discoverMutation = useDiscoverObject(objId) + const syncSheetsMutation = useSyncSheets(objId) + const createDevice = useCreateDevice(objId) + const updateDevice = useUpdateDevice(objId) + const deleteDevice = useDeleteDevice(objId) + const previewCSV = usePreviewCSV(objId) + const importCSV = useImportCSV(objId) + const createRack = useCreateRack(objId) + const deleteRack = useDeleteRack(objId) + const createZone = useCreateZone(objId) + const deleteZone = useDeleteZone(objId) + + const [deviceModal, setDeviceModal] = useState(false) + const [editDevice, setEditDevice] = useState(null) + const [csvModal, setCsvModal] = useState(false) + const [rackModal, setRackModal] = useState(false) + const [sheetsModal, setSheetsModal] = useState(false) + const [csvFile, setCsvFile] = useState(null) + const [csvPreview, setCsvPreview] = useState(null) + const [searchText, setSearchText] = useState('') + + const [deviceForm] = Form.useForm() + const [rackForm] = Form.useForm() + const [zoneForm] = Form.useForm() + const [sheetsForm] = Form.useForm() + + const rackOptions = useMemo( + () => (racks ?? []).map((r) => ({ value: r.id, label: r.name })), + [racks] + ) + + const handleSync = async () => { + try { + const result = await syncMutation.mutateAsync() + message.success(`Синхронизировано: +${result.created} новых, ${result.updated} обновлено`) + } catch { + message.error('Ошибка синхронизации') + } + } + + 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) + } + } catch { + message.error('Ошибка SSH discovery') + } + } + + const handleSyncSheets = async () => { + try { + const values = await sheetsForm.validateFields() + const result = await syncSheetsMutation.mutateAsync({ + spreadsheet_id: values.spreadsheet_id, + sheet_name: values.sheet_name || undefined, + }) + message.success(`Google Sheets: +${result.created} создано, ${result.updated} обновлено`) + setSheetsModal(false) + sheetsForm.resetFields() + } catch (err: any) { + const detail = err?.response?.data?.detail + if (detail) message.error(detail) + } + } + + const openAddDevice = () => { + deviceForm.resetFields() + setEditDevice(null) + setDeviceModal(true) + } + + const openEditDevice = (row: DeviceItem) => { + deviceForm.resetFields() + deviceForm.setFieldsValue({ + ip: row.ip, + hostname: row.hostname ?? '', + category: row.category, + role: row.role, + vendor: (row as any).vendor ?? '', + model: row.model ?? '', + mac: row.mac ?? '', + rack_id: row.rack_id ?? undefined, + location: (row as any).location ?? undefined, + monitor_enabled: row.monitor_enabled ?? false, + ssh_user_override: row.ssh_user_override ?? '', + ssh_port_override: row.ssh_port_override ?? undefined, + }) + setEditDevice(row) + setDeviceModal(true) + } + + const handleSaveDevice = async () => { + const values = await deviceForm.validateFields() + const payload = stripEmpty(values) + if ('rack_id' in values && values.rack_id === undefined) { + payload.rack_id = null + } + try { + if (editDevice) { + await updateDevice.mutateAsync({ id: editDevice.id, body: payload as any }) + message.success('Устройство обновлено') + } else { + await createDevice.mutateAsync(payload as any) + message.success('Устройство добавлено') + } + setDeviceModal(false) + setEditDevice(null) + deviceForm.resetFields() + } catch { + message.error(editDevice ? 'Ошибка при обновлении' : 'Ошибка при добавлении') + } + } + + const handleDeleteDevice = async (row: DeviceItem) => { + try { + await deleteDevice.mutateAsync(row.id) + message.success(`Устройство ${row.ip} удалено`) + } catch { + message.error('Ошибка при удалении устройства') + } + } + + const handleCsvPreview = async () => { + if (!csvFile) return + try { + const preview = await previewCSV.mutateAsync(csvFile) + setCsvPreview(preview) + } catch { + message.error('Не удалось разобрать CSV') + } + } + + const handleCsvImport = async () => { + if (!csvFile) return + try { + const result = await importCSV.mutateAsync(csvFile) + message.success(`Импортировано: +${result.created} создано, ${result.updated} обновлено`) + setCsvModal(false) + setCsvFile(null) + setCsvPreview(null) + } catch { + message.error('Ошибка при импорте CSV') + } + } + + const closeCsvModal = () => { + setCsvModal(false) + setCsvFile(null) + setCsvPreview(null) + } + + const handleAddZone = async () => { + const values = await zoneForm.validateFields() + try { + await createZone.mutateAsync(stripEmpty(values) as any) + message.success('Зона добавлена') + zoneForm.resetFields() + } catch { + message.error('Ошибка при добавлении зоны') + } + } + + const handleAddRack = async () => { + const values = await rackForm.validateFields() + try { + await createRack.mutateAsync(stripEmpty(values) as any) + message.success('Стойка добавлена') + rackForm.resetFields() + } catch { + message.error('Ошибка при добавлении стойки') + } + } + + const filteredDevices = useMemo(() => { + if (!searchText) return devices ?? [] + const q = searchText.toLowerCase() + return (devices ?? []).filter( + (d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q) + ) + }, [devices, searchText]) + + if (objLoading) return + + const deviceColumns = [ + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + render: (ip: string) => {ip}, + }, + { + title: 'Hostname', + dataIndex: 'hostname', + key: 'hostname', + render: (h: string | null) => h ?? '—', + }, + { + title: 'Стойка / локация', + key: 'location', + render: (_: unknown, row: DeviceItem) => { + if (row.rack_name) return {row.rack_name} + const loc = (row 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, + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + render: (s: string) => , + }, + { + title: 'Последний ping', + dataIndex: 'last_seen', + key: 'last_seen', + render: (ts: string | null, row: DeviceItem) => + ts ? ( + + {row.last_ping_ms}ms + + ) : '—', + }, + { + title: 'Источник', + dataIndex: 'source', + key: 'source', + render: (s: string) => {s}, + }, + { + title: '', + key: 'actions', + width: 100, + render: (_: unknown, row: DeviceItem) => ( + + + + + + + + + + + + + setSearchText(e.target.value)} + /> + + + + {/* Rack & Zone Management Modal */} + { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }} + footer={null} + width={820} + > + Зоны +
+ + + + + + + + + + +
+ + + + Стойки +
+ + + + + + + + + + +
+ + + {/* CSV Import Modal */} + + + {!csvPreview && } + {csvPreview && } + + } + > + {!csvPreview ? ( + <> + + Формат CSV: ip, hostname, category, role, model, mac, notes + + { setCsvFile(file); return false }} + onRemove={() => setCsvFile(null)} + fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []} + > +

+

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

+
+ + ) : ( + <> + {csvPreview.errors.length > 0 && ( + `Строка ${e.row}: ${e.error}`).join('\n')} /> + )} + {csvPreview.would_create.length > 0 && ( + <> + Будет создано: {csvPreview.would_create.length} +
+ + )} + {csvPreview.would_update.length > 0 && ( + <> + Будет обновлено: {csvPreview.would_update.length} +
+ + )} + + )} + + + {/* Add / Edit Device Modal */} + { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }} + onOk={handleSaveDevice} + confirmLoading={createDevice.isPending || updateDevice.isPending} + okText={editDevice ? 'Сохранить' : 'Добавить'} + width={640} + > +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Мониторинг (автоматический ping) + + + SSH (переопределить настройки объекта) + + + + + + + + + + + + + + + + + + + + + + {/* Google Sheets Sync Modal */} + { setSheetsModal(false); sheetsForm.resetFields() }} + onOk={handleSyncSheets} + confirmLoading={syncSheetsMutation.isPending} + okText="Синхронизировать" + > +
+ + + + + + + + + Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes + +
+ + ) +} diff --git a/frontend/src/pages/Objects.tsx b/frontend/src/pages/Objects.tsx index ddc2104..594fc3f 100644 --- a/frontend/src/pages/Objects.tsx +++ b/frontend/src/pages/Objects.tsx @@ -1,8 +1,10 @@ -import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' +import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons' import { + AutoComplete, Button, Checkbox, Col, + Collapse, Divider, Form, Input, @@ -11,7 +13,6 @@ import { Popconfirm, Row, Space, - Table, Tag, Typography, message, @@ -21,15 +22,23 @@ import { useNavigate } from 'react-router-dom' import { useCreateObject, useDeleteObject, + useObjectsMeta, useUpdateObject, useObjects, type ObjectItem, } from '../api/objects' import { stripEmpty } from '../utils/form' -export function ObjectsPage() { +interface Props { + mode: 'inventory' | 'work' +} + +const UNGROUPED = '__none__' + +export function ObjectsPage({ mode }: Props) { const navigate = useNavigate() const { data: objects, isLoading } = useObjects() + const { data: meta } = useObjectsMeta() const createObject = useCreateObject() const updateObject = useUpdateObject() const deleteObject = useDeleteObject() @@ -39,12 +48,37 @@ export function ObjectsPage() { const [searchText, setSearchText] = useState('') const [form] = Form.useForm() + const objectUrl = (id: number) => + mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}` + const filteredObjects = useMemo(() => { if (!searchText) return objects ?? [] const q = searchText.toLowerCase() - return (objects ?? []).filter((o) => o.name.toLowerCase().includes(q)) + return (objects ?? []).filter( + (o) => + o.name.toLowerCase().includes(q) || + (o.city ?? '').toLowerCase().includes(q) || + (o.client ?? '').toLowerCase().includes(q) + ) }, [objects, searchText]) + // Group: city → objects[]. Null city → UNGROUPED key + const cityGroups = useMemo(() => { + const map = new Map() + for (const obj of filteredObjects) { + const key = obj.city ?? UNGROUPED + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(obj) + } + // Sort: named cities first (alphabetical), ungrouped last + const sorted = [...map.entries()].sort(([a], [b]) => { + if (a === UNGROUPED) return 1 + if (b === UNGROUPED) return -1 + return a.localeCompare(b, 'ru') + }) + return sorted + }, [filteredObjects]) + const openCreate = () => { form.resetFields() setEditTarget(null) @@ -57,6 +91,8 @@ export function ObjectsPage() { name: row.name, description: row.description ?? '', server_ip: row.server_ip ?? '', + city: row.city ?? '', + client: row.client ?? '', ssh_user: row.ssh_user ?? '', db_host: row.db_host ?? '', }) @@ -78,7 +114,7 @@ export function ObjectsPage() { setModalOpen(false) form.resetFields() } catch { - message.error(editTarget === null ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта') + message.error(editTarget === null ? 'Ошибка при создании' : 'Ошибка при обновлении') } } @@ -88,92 +124,72 @@ export function ObjectsPage() { message.success(`Объект "${row.name}" удалён`) } catch (err: any) { if (err?.response?.status === 409) { - message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства') + message.error('Нельзя удалить объект с устройствами') } else { - message.error(err?.response?.data?.detail ?? 'Ошибка при удалении объекта') + message.error(err?.response?.data?.detail ?? 'Ошибка при удалении') } } } - const columns = [ - { - title: 'Название', - dataIndex: 'name', - key: 'name', - render: (name: string, row: ObjectItem) => ( - - ), - }, - { - title: 'Сервер', - dataIndex: 'server_ip', - key: 'server_ip', - render: (ip: string | null) => - ip ?? , - }, - { - title: 'SSH', - dataIndex: 'ssh_user', - key: 'ssh_user', - render: (u: string | null) => - u ? {u} : , - }, - { - title: 'Теги', - dataIndex: 'tags', - key: 'tags', - render: (tags: ObjectItem['tags']) => - tags.map((t) => ( - - {t.name} - - )), - }, - { - title: 'Статус', - dataIndex: 'is_active', - key: 'is_active', - render: (active: boolean) => - active ? Активен : Неактивен, - }, - { - title: '', - key: 'actions', - width: 160, - render: (_: unknown, row: ObjectItem) => ( - - - + {obj.client && {obj.client}} + {obj.server_ip && {obj.server_ip}} + {obj.tags.map((t) => ( + {t.name} + ))} + {!obj.is_active && Неактивен} + {mode === 'inventory' && ( + + + {mode === 'inventory' && ( + + )} -
+ {isLoading ? ( + Загрузка... + ) : cityGroups.length === 0 ? ( + Нет объектов + ) : ( + city)} + items={collapseItems} + /> + )} + {/* Create / Edit modal — only relevant in inventory mode */}
@@ -233,6 +254,31 @@ export function ObjectsPage() { + +
+ + ({ value: c }))} + filterOption={(input, opt) => + (opt?.value ?? '').toLowerCase().includes(input.toLowerCase()) + } + placeholder="Санкт-Петербург" + /> + + + + + ({ value: c }))} + filterOption={(input, opt) => + (opt?.value ?? '').toLowerCase().includes(input.toLowerCase()) + } + placeholder="Авангард" + /> + + + + @@ -260,7 +306,7 @@ export function ObjectsPage() { Подключение к БД объекта{' '} - (для синхронизации устройств) + (для синхронизации) diff --git a/frontend/src/pages/WorkDevices.tsx b/frontend/src/pages/WorkDevices.tsx new file mode 100644 index 0000000..22145f9 --- /dev/null +++ b/frontend/src/pages/WorkDevices.tsx @@ -0,0 +1,304 @@ +import { ThunderboltOutlined } from '@ant-design/icons' +import { + Button, + Col, + Form, + Input, + Modal, + Row, + Select, + Space, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAllDevices, useObjectsMeta, type DeviceItem } from '../api/objects' +import { useObjects } from '../api/objects' +import { useActions, useCreateTask } from '../api/tasks' +import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' +import dayjs from 'dayjs' + +const CATEGORY_OPTIONS = [ + { value: 'main_server', label: 'Главный сервер' }, + { value: 'vm', label: 'Виртуалка / сервис' }, + { value: 'router', label: 'Роутер / шлюз' }, + { value: 'embedded', label: 'Пром-ПК' }, + { value: 'camera', label: 'Камера' }, + { value: 'io_board', label: 'Модуль ввода/вывода' }, + { value: 'bank_terminal', label: 'Банковский терминал' }, + { value: 'cash_register', label: 'ККТ' }, + { value: 'other', label: 'Другое' }, +] + +export function WorkDevicesPage() { + const navigate = useNavigate() + const { data: meta } = useObjectsMeta() + const { data: allObjects } = useObjects() + const { data: actions } = useActions() + const createTask = useCreateTask() + + const [filterCategory, setFilterCategory] = useState([]) + const [filterCity, setFilterCity] = useState() + const [filterClient, setFilterClient] = useState() + const [filterObjectId, setFilterObjectId] = useState() + const [filterStatus, setFilterStatus] = useState() + const [selectedRowKeys, setSelectedRowKeys] = useState([]) + + const [taskModal, setTaskModal] = useState(false) + const [selectedAction, setSelectedAction] = useState('') + const [form] = Form.useForm() + + const { data: devices, isLoading } = useAllDevices({ + category: filterCategory.length > 0 ? filterCategory : undefined, + city: filterCity, + client: filterClient, + object_id: filterObjectId, + status: filterStatus, + }) + + const currentAction = actions?.find((a) => a.name === selectedAction) + + const openTaskModal = () => { + setTaskModal(true) + setSelectedAction('') + form.resetFields() + } + + const submitTask = async () => { + const values = await form.validateFields() + const { action, ...params } = values + + // If specific devices selected — run on each; else run with current filters as scope + const scope = + selectedRowKeys.length > 0 + ? { type: 'device_list', ids: selectedRowKeys } + : { + type: 'filter', + category: filterCategory.length > 0 ? filterCategory : undefined, + city: filterCity, + client: filterClient, + object_id: filterObjectId, + } + + try { + const task = await createTask.mutateAsync({ type: action, target_scope: scope, params }) + message.success('Задача создана') + setTaskModal(false) + navigate(`/tasks/${task.id}`) + } catch { + message.error('Ошибка создания задачи') + } + } + + const objectOptions = (allObjects ?? []).map((o) => ({ + value: o.id, + label: o.city ? `${o.city} — ${o.name}` : o.name, + })) + + const columns = [ + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + render: (ip: string) => {ip}, + }, + { + title: 'Hostname', + dataIndex: 'hostname', + key: 'hostname', + render: (h: string | null) => h ?? '—', + }, + { + title: 'Категория', + dataIndex: 'category', + key: 'category', + render: (c: string) => , + }, + { + title: 'Объект', + dataIndex: 'object_id', + key: 'object_id', + render: (objId: number) => { + const obj = (allObjects ?? []).find((o) => o.id === objId) + if (!obj) return objId + return ( + + {obj.city && {obj.city}} + + + ) + }, + }, + { + title: 'Клиент', + key: 'client', + render: (_: unknown, row: DeviceItem) => { + const obj = (allObjects ?? []).find((o) => o.id === row.object_id) + return obj?.client ? {obj.client} : + }, + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + render: (s: string) => , + }, + { + title: 'Последний ping', + dataIndex: 'last_seen', + key: 'last_seen', + render: (ts: string | null, row: DeviceItem) => + ts ? ( + + {row.last_ping_ms}ms + + ) : '—', + }, + ] + + return ( +
+
+ + Все устройства + + +
+ + {/* Filters */} + +
+ ({ value: c, label: c }))} + value={filterCity} + onChange={setFilterCity} + /> + + + + + +
setSelectedRowKeys(keys as number[]), + }} + /> + + {/* Task Modal */} + setTaskModal(false)} + onOk={submitTask} + confirmLoading={createTask.isPending} + okText="Запустить" + > + {selectedRowKeys.length > 0 ? ( + + Выбрано устройств: {selectedRowKeys.length} + + ) : ( + + Задача будет запущена на все устройства из текущей выборки ({devices?.length ?? 0} шт.) + + )} + + + + + ))} + + + + ) +} diff --git a/frontend/src/pages/WorkObjectDetail.tsx b/frontend/src/pages/WorkObjectDetail.tsx new file mode 100644 index 0000000..b0bca01 --- /dev/null +++ b/frontend/src/pages/WorkObjectDetail.tsx @@ -0,0 +1,254 @@ +import { + PlayCircleOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' +import { + Breadcrumb, + Button, + Col, + Descriptions, + Form, + Input, + Modal, + Row, + Select, + Space, + Spin, + Table, + Tag, + Tooltip, + Typography, + message, +} from 'antd' +import { useMemo, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useDevices, useObject, type DeviceItem } from '../api/objects' +import { useRacks } from '../api/racks' +import { useActions, useCreateTask } from '../api/tasks' +import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' +import dayjs from 'dayjs' + +export function WorkObjectDetailPage() { + const { id } = useParams<{ id: string }>() + const objId = Number(id) + const navigate = useNavigate() + + const { data: obj, isLoading: objLoading } = useObject(objId) + const { data: devices, isLoading: devLoading } = useDevices(objId) + const { data: racks } = useRacks(objId) + const { data: actions } = useActions() + const createTask = useCreateTask() + + const [taskModal, setTaskModal] = useState<{ scope: object; title: string } | null>(null) + const [selectedAction, setSelectedAction] = useState('') + const [searchText, setSearchText] = useState('') + const [form] = Form.useForm() + + const currentAction = actions?.find((a) => a.name === selectedAction) + + const openTaskModal = (scope: object, title: string) => { + setTaskModal({ scope, title }) + setSelectedAction('') + form.resetFields() + } + + const submitTask = async () => { + const values = await form.validateFields() + const { action, ...params } = values + try { + const task = await createTask.mutateAsync({ + type: action, + target_scope: taskModal!.scope, + params, + }) + message.success('Задача создана') + setTaskModal(null) + navigate(`/tasks/${task.id}`) + } catch { + message.error('Ошибка создания задачи') + } + } + + const filteredDevices = useMemo(() => { + if (!searchText) return devices ?? [] + const q = searchText.toLowerCase() + return (devices ?? []).filter( + (d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q) + ) + }, [devices, searchText]) + + if (objLoading) return + + const deviceColumns = [ + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + render: (ip: string) => {ip}, + }, + { + title: 'Hostname', + dataIndex: 'hostname', + key: 'hostname', + render: (h: string | null) => h ?? '—', + }, + { + title: 'Стойка', + dataIndex: 'rack_name', + key: 'rack_name', + render: (name: string | null) => + name ? {name} : , + filters: [ + { text: 'Без стойки', value: '__none__' }, + ...(racks ?? []).map((r) => ({ text: r.name, value: r.name })), + ], + onFilter: (value: unknown, record: DeviceItem) => { + if (value === '__none__') return record.rack_name === null + return record.rack_name === value + }, + }, + { + title: 'Категория', + dataIndex: 'category', + key: 'category', + render: (c: string) => , + filters: [ + { text: 'Главный сервер', value: 'main_server' }, + { text: 'Виртуалка', value: 'vm' }, + { text: 'Роутер', value: 'router' }, + { text: 'Пром-ПК', value: 'embedded' }, + { text: 'Камера', value: 'camera' }, + { text: 'Модуль В/В', value: 'io_board' }, + { text: 'Банк. терминал', value: 'bank_terminal' }, + { text: 'ККТ', value: 'cash_register' }, + { text: 'Другое', value: 'other' }, + ], + onFilter: (value: unknown, record: DeviceItem) => record.category === value, + }, + { + title: 'Статус', + dataIndex: 'status', + key: 'status', + render: (s: string) => , + }, + { + title: 'Последний ping', + dataIndex: 'last_seen', + key: 'last_seen', + render: (ts: string | null, row: DeviceItem) => + ts ? ( + + {row.last_ping_ms}ms + + ) : '—', + }, + { + title: '', + key: 'actions', + width: 110, + render: (_: unknown, row: DeviceItem) => ( + + ), + }, + ] + + // Breadcrumb: Работа → [City →] Object + const breadcrumbItems = [ + { title: navigate('/work/objects')}>Работа }, + ...(obj?.city ? [{ title: navigate('/work/objects')}>{obj.city} }] : []), + { title: obj?.name }, + ] + + return ( +
+ + + +
+ + {obj?.server_ip ?? '—'} + {obj?.client ?? '—'} + {obj?.ssh_user ?? '—'} + + + + + + + + + + + + setSearchText(e.target.value)} + /> + +
+ + {/* Task Modal */} + setTaskModal(null)} + onOk={submitTask} + confirmLoading={createTask.isPending} + okText="Запустить" + > +
+ + + + ))} + +
+ + ) +}