vibe cringe
This commit is contained in:
parent
2ba10307b3
commit
c724a1c175
17 changed files with 2036 additions and 115 deletions
|
|
@ -2,7 +2,11 @@
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(xargs grep:*)",
|
"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\")"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
34
backend/alembic/versions/0008_inventory.py
Normal file
34
backend/alembic/versions/0008_inventory.py
Normal file
|
|
@ -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")
|
||||||
28
backend/alembic/versions/0009_object_city_client.py
Normal file
28
backend/alembic/versions/0009_object_city_client.py
Normal file
|
|
@ -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")
|
||||||
|
|
@ -6,7 +6,7 @@ from fastapi.openapi.utils import get_openapi
|
||||||
|
|
||||||
from app.middleware.auth import AuthMiddleware
|
from app.middleware.auth import AuthMiddleware
|
||||||
from app.plugins.loader import load_plugins
|
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.monitor import start_monitor, stop_monitor
|
||||||
from app.workers.startup import recover_stale_tasks
|
from app.workers.startup import recover_stale_tasks
|
||||||
|
|
||||||
|
|
@ -41,6 +41,7 @@ def create_app() -> FastAPI:
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(objects.router)
|
app.include_router(objects.router)
|
||||||
|
app.include_router(global_devices.router)
|
||||||
app.include_router(devices.router)
|
app.include_router(devices.router)
|
||||||
app.include_router(racks.router)
|
app.include_router(racks.router)
|
||||||
app.include_router(zones.router)
|
app.include_router(zones.router)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from .base import Base, TimestampMixin
|
from .base import Base, TimestampMixin
|
||||||
|
|
||||||
DEVICE_CATEGORIES = ("raspberry", "mikrotik", "camera", "embedded", "server", "other")
|
DEVICE_CATEGORIES = (
|
||||||
DEVICE_ROLES = ("entry", "exit", "server", "nvr", "switch", "other")
|
"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_STATUSES = ("online", "offline", "unknown")
|
||||||
DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync")
|
DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync")
|
||||||
|
|
||||||
|
|
@ -31,6 +47,7 @@ class Device(Base, TimestampMixin):
|
||||||
# Classification
|
# Classification
|
||||||
category: Mapped[str] = mapped_column(String(20), nullable=False, default="other")
|
category: Mapped[str] = mapped_column(String(20), nullable=False, default="other")
|
||||||
role: 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)
|
model: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
firmware_version: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
firmware_version: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
|
@ -64,6 +81,18 @@ class Device(Base, TimestampMixin):
|
||||||
index=True,
|
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)
|
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ class Object(Base, TimestampMixin):
|
||||||
ssh_pass_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
ssh_pass_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
ssh_port: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
|
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)
|
notes: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True)
|
||||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
|
|
||||||
58
backend/app/routers/global_devices.py
Normal file
58
backend/app/routers/global_devices.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, distinct
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
@ -9,8 +9,9 @@ from app.models.object import Object
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.admin import SheetsSyncRequest
|
from app.schemas.admin import SheetsSyncRequest
|
||||||
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate, SyncResult
|
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult
|
||||||
from app.services import crypto
|
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.object_db import sync_from_object_db
|
||||||
from app.services.sheets import sync_from_sheets
|
from app.services.sheets import sync_from_sheets
|
||||||
|
|
||||||
|
|
@ -34,10 +35,30 @@ async def _load_object(db: AsyncSession, obj_id: int) -> Object:
|
||||||
return obj
|
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])
|
@router.get("", response_model=list[ObjectRead])
|
||||||
async def list_objects(
|
async def list_objects(
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
tag_id: int | None = None,
|
tag_id: int | None = None,
|
||||||
|
city: str | None = None,
|
||||||
|
client: str | None = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(get_current_user),
|
_: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
|
@ -46,7 +67,11 @@ async def list_objects(
|
||||||
query = query.where(Object.is_active == is_active)
|
query = query.where(Object.is_active == is_active)
|
||||||
if tag_id is not None:
|
if tag_id is not None:
|
||||||
query = query.where(Object.tags.any(Tag.id == tag_id))
|
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)
|
result = await db.execute(query)
|
||||||
return result.scalars().all()
|
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)
|
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
|
||||||
async def sync_devices_from_sheets(
|
async def sync_devices_from_sheets(
|
||||||
obj_id: int,
|
obj_id: int,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ class ObjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
server_ip: Optional[str] = None
|
server_ip: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
client: Optional[str] = None
|
||||||
# Object DB sync settings
|
# Object DB sync settings
|
||||||
db_host: Optional[str] = None
|
db_host: Optional[str] = None
|
||||||
db_port: int = 5432
|
db_port: int = 5432
|
||||||
|
|
@ -33,6 +35,8 @@ class ObjectUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
server_ip: Optional[str] = None
|
server_ip: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
client: Optional[str] = None
|
||||||
db_host: Optional[str] = None
|
db_host: Optional[str] = None
|
||||||
db_port: Optional[int] = None
|
db_port: Optional[int] = None
|
||||||
db_name: Optional[str] = None
|
db_name: Optional[str] = None
|
||||||
|
|
@ -55,6 +59,8 @@ class ObjectRead(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
server_ip: Optional[str] = None
|
server_ip: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
client: Optional[str] = None
|
||||||
db_host: Optional[str] = None
|
db_host: Optional[str] = None
|
||||||
db_port: int
|
db_port: int
|
||||||
db_name: Optional[str] = None
|
db_name: Optional[str] = None
|
||||||
|
|
@ -74,6 +80,11 @@ class ObjectRead(BaseModel):
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectMetaResponse(BaseModel):
|
||||||
|
cities: list[str]
|
||||||
|
clients: list[str]
|
||||||
|
|
||||||
|
|
||||||
class SyncResult(BaseModel):
|
class SyncResult(BaseModel):
|
||||||
created: int
|
created: int
|
||||||
updated: int
|
updated: int
|
||||||
|
|
|
||||||
299
backend/app/services/device_discovery.py
Normal file
299
backend/app/services/device_discovery.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -3,11 +3,13 @@ import { AppLayout } from './components/AppLayout'
|
||||||
import { AdminPage } from './pages/Admin'
|
import { AdminPage } from './pages/Admin'
|
||||||
import { AlertsPage } from './pages/Alerts'
|
import { AlertsPage } from './pages/Alerts'
|
||||||
import { LoginPage } from './pages/Login'
|
import { LoginPage } from './pages/Login'
|
||||||
import { ObjectDetailPage } from './pages/ObjectDetail'
|
|
||||||
import { ObjectsPage } from './pages/Objects'
|
import { ObjectsPage } from './pages/Objects'
|
||||||
import { SnapshotsPage } from './pages/Snapshots'
|
import { SnapshotsPage } from './pages/Snapshots'
|
||||||
import { TaskDetailPage } from './pages/TaskDetail'
|
import { TaskDetailPage } from './pages/TaskDetail'
|
||||||
import { TasksPage } from './pages/Tasks'
|
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'
|
import { useAuthStore } from './store/auth'
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
|
|
@ -27,14 +29,27 @@ export default function App() {
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/objects" replace />} />
|
<Route index element={<Navigate to="/inventory/objects" replace />} />
|
||||||
<Route path="admin" element={<AdminPage />} />
|
|
||||||
|
{/* Инвентарь */}
|
||||||
|
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
|
||||||
|
<Route path="inventory/objects/:id" element={<InventoryObjectDetailPage />} />
|
||||||
|
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
|
||||||
|
|
||||||
|
{/* Работа */}
|
||||||
|
<Route path="work/objects" element={<ObjectsPage mode="work" />} />
|
||||||
|
<Route path="work/objects/:id" element={<WorkObjectDetailPage />} />
|
||||||
|
<Route path="work/devices" element={<WorkDevicesPage />} />
|
||||||
|
|
||||||
|
{/* Общие */}
|
||||||
<Route path="alerts" element={<AlertsPage />} />
|
<Route path="alerts" element={<AlertsPage />} />
|
||||||
<Route path="objects" element={<ObjectsPage />} />
|
|
||||||
<Route path="objects/:id" element={<ObjectDetailPage />} />
|
|
||||||
<Route path="objects/:id/snapshots" element={<SnapshotsPage />} />
|
|
||||||
<Route path="tasks" element={<TasksPage />} />
|
<Route path="tasks" element={<TasksPage />} />
|
||||||
<Route path="tasks/:id" element={<TaskDetailPage />} />
|
<Route path="tasks/:id" element={<TaskDetailPage />} />
|
||||||
|
<Route path="admin" element={<AdminPage />} />
|
||||||
|
|
||||||
|
{/* Старые URL — редирект для совместимости */}
|
||||||
|
<Route path="objects" element={<Navigate to="/inventory/objects" replace />} />
|
||||||
|
<Route path="objects/:id" element={<Navigate to="/inventory/objects" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,18 @@ export interface ObjectItem {
|
||||||
server_ip: string | null
|
server_ip: string | null
|
||||||
ssh_user: string | null
|
ssh_user: string | null
|
||||||
db_host: string | null
|
db_host: string | null
|
||||||
|
city: string | null
|
||||||
|
client: string | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
tags: { id: number; name: string; color: string }[]
|
tags: { id: number; name: string; color: string }[]
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ObjectMeta {
|
||||||
|
cities: string[]
|
||||||
|
clients: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceItem {
|
export interface DeviceItem {
|
||||||
id: number
|
id: number
|
||||||
object_id: number
|
object_id: number
|
||||||
|
|
@ -80,6 +87,8 @@ export interface ObjectCreatePayload {
|
||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
server_ip?: string
|
server_ip?: string
|
||||||
|
city?: string
|
||||||
|
client?: string
|
||||||
ssh_user?: string
|
ssh_user?: string
|
||||||
ssh_password?: string
|
ssh_password?: string
|
||||||
ssh_port?: number
|
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<SyncResult>(`/api/v1/objects/${objId}/discover`).then((r) => r.data),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export interface SheetsSyncPayload {
|
export interface SheetsSyncPayload {
|
||||||
spreadsheet_id: string
|
spreadsheet_id: string
|
||||||
sheet_name?: 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<DeviceItem[]>('/api/v1/devices', { params })
|
||||||
|
.then((r) => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useObjectsMeta = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['objects-meta'],
|
||||||
|
queryFn: () => api.get<ObjectMeta>('/api/v1/objects/meta').then((r) => r.data),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
export const useSyncSheets = (objId: number) => {
|
export const useSyncSheets = (objId: number) => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import {
|
import {
|
||||||
ApartmentOutlined,
|
AppstoreOutlined,
|
||||||
BellOutlined,
|
BellOutlined,
|
||||||
|
ControlOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
|
|
@ -21,7 +23,6 @@ export function AppLayout() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { username, role, refreshToken, clearTokens } = useAuthStore()
|
const { username, role, refreshToken, clearTokens } = useAuthStore()
|
||||||
|
|
||||||
// Open alert count for sidebar badge — refresh every 60s
|
|
||||||
const { data: openAlerts } = useAlerts({
|
const { data: openAlerts } = useAlerts({
|
||||||
status: 'open',
|
status: 'open',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
|
@ -29,23 +30,27 @@ export function AppLayout() {
|
||||||
})
|
})
|
||||||
const openCount = openAlerts?.length ?? 0
|
const openCount = openAlerts?.length ?? 0
|
||||||
|
|
||||||
// Global monitor SSE — invalidates caches when monitor events arrive
|
|
||||||
useMonitorSSE({
|
useMonitorSSE({
|
||||||
onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
|
onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
|
||||||
onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
|
onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
|
||||||
onDeviceStatus: (ev) => {
|
onDeviceStatus: (ev) => {
|
||||||
// Invalidate devices for the affected object so status badge updates live
|
|
||||||
qc.invalidateQueries({ queryKey: ['devices', ev.object_id] })
|
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'
|
? 'tasks'
|
||||||
: location.pathname.startsWith('/alerts')
|
: location.pathname.startsWith('/alerts')
|
||||||
? 'alerts'
|
? 'alerts'
|
||||||
: location.pathname.startsWith('/admin')
|
: location.pathname.startsWith('/admin')
|
||||||
? 'admin'
|
? 'admin'
|
||||||
: 'objects'
|
: ''
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -70,11 +75,38 @@ export function AppLayout() {
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'objects',
|
key: 'inventory-group',
|
||||||
icon: <ApartmentOutlined />,
|
type: 'group',
|
||||||
|
label: 'Инвентарь',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'inventory-objects',
|
||||||
|
icon: <DatabaseOutlined />,
|
||||||
label: 'Объекты',
|
label: 'Объекты',
|
||||||
onClick: () => navigate('/objects'),
|
onClick: () => navigate('/inventory/objects'),
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'work-group',
|
||||||
|
type: 'group',
|
||||||
|
label: 'Работа',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'work-objects',
|
||||||
|
icon: <ControlOutlined />,
|
||||||
|
label: 'По объектам',
|
||||||
|
onClick: () => navigate('/work/objects'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'work-devices',
|
||||||
|
icon: <AppstoreOutlined />,
|
||||||
|
label: 'Все устройства',
|
||||||
|
onClick: () => navigate('/work/devices'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
{
|
{
|
||||||
key: 'alerts',
|
key: 'alerts',
|
||||||
icon: <BellOutlined />,
|
icon: <BellOutlined />,
|
||||||
|
|
|
||||||
718
frontend/src/pages/InventoryObjectDetail.tsx
Normal file
718
frontend/src/pages/InventoryObjectDetail.tsx
Normal file
|
|
@ -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<DeviceItem | null>(null)
|
||||||
|
const [csvModal, setCsvModal] = useState(false)
|
||||||
|
const [rackModal, setRackModal] = useState(false)
|
||||||
|
const [sheetsModal, setSheetsModal] = useState(false)
|
||||||
|
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||||
|
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(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 <Spin />
|
||||||
|
|
||||||
|
const deviceColumns = [
|
||||||
|
{
|
||||||
|
title: 'IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
key: 'ip',
|
||||||
|
render: (ip: string) => <code>{ip}</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hostname',
|
||||||
|
dataIndex: 'hostname',
|
||||||
|
key: 'hostname',
|
||||||
|
render: (h: string | null) => h ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Стойка / локация',
|
||||||
|
key: 'location',
|
||||||
|
render: (_: unknown, row: DeviceItem) => {
|
||||||
|
if (row.rack_name) return <Tag color="blue">{row.rack_name}</Tag>
|
||||||
|
const loc = (row as any).location
|
||||||
|
if (loc === 'server_room') return <Tag color="purple">Серверная</Tag>
|
||||||
|
if (loc === 'street') return <Tag color="orange">Улица</Tag>
|
||||||
|
return <Typography.Text type="secondary">—</Typography.Text>
|
||||||
|
},
|
||||||
|
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) => <CategoryTag category={c} />,
|
||||||
|
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) => <DeviceStatusBadge status={s} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Последний ping',
|
||||||
|
dataIndex: 'last_seen',
|
||||||
|
key: 'last_seen',
|
||||||
|
render: (ts: string | null, row: DeviceItem) =>
|
||||||
|
ts ? (
|
||||||
|
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
|
||||||
|
<span>{row.last_ping_ms}ms</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Источник',
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
render: (s: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{s}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 100,
|
||||||
|
render: (_: unknown, row: DeviceItem) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(row)} />
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить устройство?"
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => handleDeleteDevice(row)}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const zoneColumns = [
|
||||||
|
{ title: 'Название', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: 'Описание', dataIndex: 'description', key: 'description', render: (v: string | null) => v ?? '—' },
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 60,
|
||||||
|
render: (_: unknown, row: ZoneItem) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить зону?"
|
||||||
|
description="Стойки останутся, привязка к зоне будет снята."
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => deleteZone.mutateAsync(row.id).catch(() => message.error('Ошибка удаления зоны'))}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const rackColumns = [
|
||||||
|
{ title: 'Название', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? <Tag>{v}</Tag> : '—' },
|
||||||
|
{ title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' },
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 60,
|
||||||
|
render: (_: unknown, row: RackItem) => (
|
||||||
|
<Popconfirm
|
||||||
|
title="Удалить стойку?"
|
||||||
|
description="Устройства останутся, привязка к стойке будет снята."
|
||||||
|
okText="Удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))}
|
||||||
|
>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const previewCreateCols = [
|
||||||
|
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
|
||||||
|
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string) => v ?? '—' },
|
||||||
|
{ title: 'Категория', dataIndex: 'category', key: 'category' },
|
||||||
|
{ title: 'Роль', dataIndex: 'role', key: 'role' },
|
||||||
|
]
|
||||||
|
const previewUpdateCols = [
|
||||||
|
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
|
||||||
|
{
|
||||||
|
title: 'Изменения',
|
||||||
|
dataIndex: 'changes',
|
||||||
|
key: 'changes',
|
||||||
|
render: (changes: Record<string, { from: unknown; to: unknown }>) =>
|
||||||
|
Object.entries(changes).map(([k, v]) => `${k}: ${v.from} → ${v.to}`).join(', ') || '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Breadcrumb items: Инвентарь → [City →] Object
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> },
|
||||||
|
...(obj?.city ? [{ title: <a onClick={() => navigate('/inventory/objects')}>{obj.city}</a> }] : []),
|
||||||
|
{ title: obj?.name },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Descriptions size="small" bordered column={4}>
|
||||||
|
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="БД">{obj?.db_host ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Клиент">{obj?.client ?? '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space wrap>
|
||||||
|
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
|
||||||
|
Синхронизировать из БД
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ApiOutlined />} loading={discoverMutation.isPending} onClick={handleDiscover}>
|
||||||
|
SSH Discovery
|
||||||
|
</Button>
|
||||||
|
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
|
||||||
|
Импорт CSV
|
||||||
|
</Button>
|
||||||
|
<Button icon={<GoogleOutlined />} onClick={() => setSheetsModal(true)}>
|
||||||
|
Google Sheets
|
||||||
|
</Button>
|
||||||
|
<Button icon={<PlusOutlined />} type="primary" onClick={openAddDevice}>
|
||||||
|
Добавить устройство
|
||||||
|
</Button>
|
||||||
|
<Button icon={<FileTextOutlined />} onClick={() => navigate(`/inventory/objects/${objId}/snapshots`)}>
|
||||||
|
Снапшоты
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ThunderboltOutlined />} onClick={() => setRackModal(true)}>
|
||||||
|
Стойки и зоны
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Поиск по IP или hostname..."
|
||||||
|
allowClear
|
||||||
|
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={filteredDevices}
|
||||||
|
columns={deviceColumns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={devLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 50 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rack & Zone Management Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Стойки и зоны"
|
||||||
|
open={rackModal}
|
||||||
|
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }}
|
||||||
|
footer={null}
|
||||||
|
width={820}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>Зоны</Typography.Title>
|
||||||
|
<Form form={zoneForm} layout="inline" style={{ marginBottom: 12 }}>
|
||||||
|
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
|
||||||
|
<Input placeholder="Название зоны" style={{ width: 200 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description">
|
||||||
|
<Input placeholder="Описание (опц.)" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddZone} loading={createZone.isPending}>
|
||||||
|
Добавить зону
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table dataSource={zones ?? []} columns={zoneColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: 'Нет зон' }} style={{ marginBottom: 24 }} />
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Typography.Title level={5}>Стойки</Typography.Title>
|
||||||
|
<Form form={rackForm} layout="inline" style={{ marginBottom: 12 }}>
|
||||||
|
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
|
||||||
|
<Input placeholder="Название стойки" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="zone_id">
|
||||||
|
<Select placeholder="Зона (опц.)" allowClear style={{ width: 150 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="location">
|
||||||
|
<Input placeholder="Расположение (опц.)" style={{ width: 150 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRack} loading={createRack.isPending}>
|
||||||
|
Добавить стойку
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table dataSource={racks ?? []} columns={rackColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: 'Нет стоек' }} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* CSV Import Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Импорт устройств из CSV"
|
||||||
|
open={csvModal}
|
||||||
|
onCancel={closeCsvModal}
|
||||||
|
width={700}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button onClick={closeCsvModal}>Отмена</Button>
|
||||||
|
{!csvPreview && <Button onClick={handleCsvPreview} loading={previewCSV.isPending} disabled={!csvFile}>Предпросмотр</Button>}
|
||||||
|
{csvPreview && <Button type="primary" onClick={handleCsvImport} loading={importCSV.isPending}>Импортировать</Button>}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!csvPreview ? (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
Формат CSV: <code>ip, hostname, category, role, model, mac, notes</code>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Upload.Dragger
|
||||||
|
accept=".csv"
|
||||||
|
maxCount={1}
|
||||||
|
beforeUpload={(file) => { setCsvFile(file); return false }}
|
||||||
|
onRemove={() => setCsvFile(null)}
|
||||||
|
fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon"><UploadOutlined style={{ fontSize: 32, color: '#1677ff' }} /></p>
|
||||||
|
<p className="ant-upload-text">Перетащите CSV-файл или нажмите для выбора</p>
|
||||||
|
</Upload.Dragger>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{csvPreview.errors.length > 0 && (
|
||||||
|
<Alert type="warning" style={{ marginBottom: 12 }} message={`${csvPreview.errors.length} строк с ошибками`} description={csvPreview.errors.map((e) => `Строка ${e.row}: ${e.error}`).join('\n')} />
|
||||||
|
)}
|
||||||
|
{csvPreview.would_create.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>Будет создано: {csvPreview.would_create.length}</Typography.Text>
|
||||||
|
<Table size="small" dataSource={csvPreview.would_create} columns={previewCreateCols} rowKey="ip" pagination={false} style={{ marginTop: 8, marginBottom: 16 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{csvPreview.would_update.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography.Text strong>Будет обновлено: {csvPreview.would_update.length}</Typography.Text>
|
||||||
|
<Table size="small" dataSource={csvPreview.would_update} columns={previewUpdateCols} rowKey="ip" pagination={false} style={{ marginTop: 8 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add / Edit Device Modal */}
|
||||||
|
<Modal
|
||||||
|
title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
|
||||||
|
open={deviceModal}
|
||||||
|
onCancel={() => { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }}
|
||||||
|
onOk={handleSaveDevice}
|
||||||
|
confirmLoading={createDevice.isPending || updateDevice.isPending}
|
||||||
|
okText={editDevice ? 'Сохранить' : 'Добавить'}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
||||||
|
<Input placeholder="192.168.1.100" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="hostname" label="Hostname">
|
||||||
|
<Input placeholder="camera-01" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="category" label="Категория" rules={[{ required: true }]}>
|
||||||
|
<Select placeholder="Выберите категорию" options={DEVICE_CATEGORIES} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="role" label="Роль">
|
||||||
|
<Select placeholder="Выберите роль" allowClear options={DEVICE_ROLES} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="vendor" label="Производитель">
|
||||||
|
<Input placeholder="Iptronic, Овен, PAX..." />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="model" label="Модель">
|
||||||
|
<Input placeholder="IPT-IPL1080S" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="rack_id" label="Стойка">
|
||||||
|
<Select placeholder="Без стойки" allowClear options={rackOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="location" label="Локация (если не в стойке)">
|
||||||
|
<Select placeholder="Не задана" allowClear options={DEVICE_LOCATIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="mac" label="MAC">
|
||||||
|
<Input placeholder="AA:BB:CC:DD:EE:FF" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item name="monitor_enabled" valuePropName="checked">
|
||||||
|
<Checkbox>Мониторинг (автоматический ping)</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={8}>
|
||||||
|
<Form.Item name="ssh_user_override" label="Логин">
|
||||||
|
<Input placeholder="caps" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={10}>
|
||||||
|
<Form.Item name="ssh_password_override" label="Пароль">
|
||||||
|
<Input.Password placeholder="••••••••" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Form.Item name="ssh_port_override" label="Порт">
|
||||||
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Google Sheets Sync Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Синхронизация из Google Sheets"
|
||||||
|
open={sheetsModal}
|
||||||
|
onCancel={() => { setSheetsModal(false); sheetsForm.resetFields() }}
|
||||||
|
onOk={handleSyncSheets}
|
||||||
|
confirmLoading={syncSheetsMutation.isPending}
|
||||||
|
okText="Синхронизировать"
|
||||||
|
>
|
||||||
|
<Form form={sheetsForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="spreadsheet_id"
|
||||||
|
label="ID таблицы Google Sheets"
|
||||||
|
rules={[{ required: true, message: 'Введите ID таблицы' }]}
|
||||||
|
extra="Часть URL после /spreadsheets/d/ и перед /edit"
|
||||||
|
>
|
||||||
|
<Input placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sheet_name" label="Название листа (необязательно)" extra="По умолчанию используется первый лист">
|
||||||
|
<Input placeholder="Sheet1" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
|
||||||
|
</Typography.Text>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
|
AutoComplete,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
|
Collapse,
|
||||||
Divider,
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
|
@ -11,7 +13,6 @@ import {
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
|
|
@ -21,15 +22,23 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
useCreateObject,
|
useCreateObject,
|
||||||
useDeleteObject,
|
useDeleteObject,
|
||||||
|
useObjectsMeta,
|
||||||
useUpdateObject,
|
useUpdateObject,
|
||||||
useObjects,
|
useObjects,
|
||||||
type ObjectItem,
|
type ObjectItem,
|
||||||
} from '../api/objects'
|
} from '../api/objects'
|
||||||
import { stripEmpty } from '../utils/form'
|
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 navigate = useNavigate()
|
||||||
const { data: objects, isLoading } = useObjects()
|
const { data: objects, isLoading } = useObjects()
|
||||||
|
const { data: meta } = useObjectsMeta()
|
||||||
const createObject = useCreateObject()
|
const createObject = useCreateObject()
|
||||||
const updateObject = useUpdateObject()
|
const updateObject = useUpdateObject()
|
||||||
const deleteObject = useDeleteObject()
|
const deleteObject = useDeleteObject()
|
||||||
|
|
@ -39,12 +48,37 @@ export function ObjectsPage() {
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const objectUrl = (id: number) =>
|
||||||
|
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
|
||||||
|
|
||||||
const filteredObjects = useMemo(() => {
|
const filteredObjects = useMemo(() => {
|
||||||
if (!searchText) return objects ?? []
|
if (!searchText) return objects ?? []
|
||||||
const q = searchText.toLowerCase()
|
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])
|
}, [objects, searchText])
|
||||||
|
|
||||||
|
// Group: city → objects[]. Null city → UNGROUPED key
|
||||||
|
const cityGroups = useMemo(() => {
|
||||||
|
const map = new Map<string, ObjectItem[]>()
|
||||||
|
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 = () => {
|
const openCreate = () => {
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setEditTarget(null)
|
setEditTarget(null)
|
||||||
|
|
@ -57,6 +91,8 @@ export function ObjectsPage() {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description ?? '',
|
description: row.description ?? '',
|
||||||
server_ip: row.server_ip ?? '',
|
server_ip: row.server_ip ?? '',
|
||||||
|
city: row.city ?? '',
|
||||||
|
client: row.client ?? '',
|
||||||
ssh_user: row.ssh_user ?? '',
|
ssh_user: row.ssh_user ?? '',
|
||||||
db_host: row.db_host ?? '',
|
db_host: row.db_host ?? '',
|
||||||
})
|
})
|
||||||
|
|
@ -78,7 +114,7 @@ export function ObjectsPage() {
|
||||||
setModalOpen(false)
|
setModalOpen(false)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
} catch {
|
} catch {
|
||||||
message.error(editTarget === null ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта')
|
message.error(editTarget === null ? 'Ошибка при создании' : 'Ошибка при обновлении')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,92 +124,72 @@ export function ObjectsPage() {
|
||||||
message.success(`Объект "${row.name}" удалён`)
|
message.success(`Объект "${row.name}" удалён`)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.response?.status === 409) {
|
if (err?.response?.status === 409) {
|
||||||
message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства')
|
message.error('Нельзя удалить объект с устройствами')
|
||||||
} else {
|
} else {
|
||||||
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении объекта')
|
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const isSubmitting = createObject.isPending || updateObject.isPending
|
||||||
{
|
|
||||||
title: 'Название',
|
const renderObjectRow = (obj: ObjectItem) => (
|
||||||
dataIndex: 'name',
|
<div
|
||||||
key: 'name',
|
key={obj.id}
|
||||||
render: (name: string, row: ObjectItem) => (
|
style={{
|
||||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/objects/${row.id}`)}>
|
display: 'flex',
|
||||||
{name}
|
alignItems: 'center',
|
||||||
</Button>
|
padding: '8px 16px',
|
||||||
),
|
borderBottom: '1px solid #f0f0f0',
|
||||||
},
|
gap: 12,
|
||||||
{
|
}}
|
||||||
title: 'Сервер',
|
>
|
||||||
dataIndex: 'server_ip',
|
|
||||||
key: 'server_ip',
|
|
||||||
render: (ip: string | null) =>
|
|
||||||
ip ?? <Typography.Text type="secondary">—</Typography.Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'SSH',
|
|
||||||
dataIndex: 'ssh_user',
|
|
||||||
key: 'ssh_user',
|
|
||||||
render: (u: string | null) =>
|
|
||||||
u ? <Tag>{u}</Tag> : <Typography.Text type="secondary">—</Typography.Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Теги',
|
|
||||||
dataIndex: 'tags',
|
|
||||||
key: 'tags',
|
|
||||||
render: (tags: ObjectItem['tags']) =>
|
|
||||||
tags.map((t) => (
|
|
||||||
<Tag key={t.id} color={t.color}>
|
|
||||||
{t.name}
|
|
||||||
</Tag>
|
|
||||||
)),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Статус',
|
|
||||||
dataIndex: 'is_active',
|
|
||||||
key: 'is_active',
|
|
||||||
render: (active: boolean) =>
|
|
||||||
active ? <Tag color="green">Активен</Tag> : <Tag color="default">Неактивен</Tag>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'actions',
|
|
||||||
width: 160,
|
|
||||||
render: (_: unknown, row: ObjectItem) => (
|
|
||||||
<Space>
|
|
||||||
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
|
|
||||||
Устройства
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
type="link"
|
||||||
icon={<EditOutlined />}
|
style={{ padding: 0, fontWeight: 500, flex: 1, textAlign: 'left' }}
|
||||||
onClick={(e) => { e.stopPropagation(); openEdit(row) }}
|
onClick={() => navigate(objectUrl(obj.id))}
|
||||||
/>
|
>
|
||||||
|
<RightOutlined style={{ fontSize: 11, marginRight: 6, color: '#999' }} />
|
||||||
|
{obj.name}
|
||||||
|
</Button>
|
||||||
|
{obj.client && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{obj.client}</Typography.Text>}
|
||||||
|
{obj.server_ip && <Typography.Text code style={{ fontSize: 12 }}>{obj.server_ip}</Typography.Text>}
|
||||||
|
{obj.tags.map((t) => (
|
||||||
|
<Tag key={t.id} color={t.color} style={{ margin: 0 }}>{t.name}</Tag>
|
||||||
|
))}
|
||||||
|
{!obj.is_active && <Tag color="default">Неактивен</Tag>}
|
||||||
|
{mode === 'inventory' && (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(obj)} />
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Удалить объект?"
|
title="Удалить объект?"
|
||||||
description="Объект можно удалить только если у него нет устройств."
|
description="Объект можно удалить только если у него нет устройств."
|
||||||
okText="Удалить"
|
okText="Удалить"
|
||||||
cancelText="Отмена"
|
cancelText="Отмена"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
onConfirm={() => handleDelete(row)}
|
onConfirm={() => handleDelete(obj)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteObject.isPending} />
|
||||||
size="small"
|
|
||||||
danger
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
loading={deleteObject.isPending}
|
|
||||||
/>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
)}
|
||||||
},
|
</div>
|
||||||
]
|
)
|
||||||
|
|
||||||
const isSubmitting = createObject.isPending || updateObject.isPending
|
const collapseItems = cityGroups.map(([city, objs]) => ({
|
||||||
|
key: city,
|
||||||
|
label: (
|
||||||
|
<Space>
|
||||||
|
<Typography.Text strong>{city === UNGROUPED ? 'Без города' : city}</Typography.Text>
|
||||||
|
<Tag>{objs.length}</Tag>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
children: (
|
||||||
|
<div style={{ margin: '-12px -16px' }}>
|
||||||
|
{objs.map(renderObjectRow)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -186,30 +202,35 @@ export function ObjectsPage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
Объекты
|
{mode === 'inventory' ? 'Инвентарь — Объекты' : 'Работа — Объекты'}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space>
|
<Space>
|
||||||
<Input.Search
|
<Input.Search
|
||||||
placeholder="Поиск по названию..."
|
placeholder="Поиск по названию, городу, клиенту..."
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 260 }}
|
style={{ width: 300 }}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{mode === 'inventory' && (
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table
|
{isLoading ? (
|
||||||
dataSource={filteredObjects}
|
<Typography.Text type="secondary">Загрузка...</Typography.Text>
|
||||||
columns={columns}
|
) : cityGroups.length === 0 ? (
|
||||||
rowKey="id"
|
<Typography.Text type="secondary">Нет объектов</Typography.Text>
|
||||||
loading={isLoading}
|
) : (
|
||||||
size="middle"
|
<Collapse
|
||||||
pagination={{ pageSize: 20 }}
|
defaultActiveKey={cityGroups.map(([city]) => city)}
|
||||||
|
items={collapseItems}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create / Edit modal — only relevant in inventory mode */}
|
||||||
<Modal
|
<Modal
|
||||||
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
|
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
|
|
@ -217,7 +238,7 @@ export function ObjectsPage() {
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
confirmLoading={isSubmitting}
|
confirmLoading={isSubmitting}
|
||||||
okText={editTarget === null ? 'Создать' : 'Сохранить'}
|
okText={editTarget === null ? 'Создать' : 'Сохранить'}
|
||||||
width={640}
|
width={680}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
|
|
@ -233,6 +254,31 @@ export function ObjectsPage() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="city" label="Город">
|
||||||
|
<AutoComplete
|
||||||
|
options={(meta?.cities ?? []).map((c) => ({ value: c }))}
|
||||||
|
filterOption={(input, opt) =>
|
||||||
|
(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
placeholder="Санкт-Петербург"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item name="client" label="Клиент">
|
||||||
|
<AutoComplete
|
||||||
|
options={(meta?.clients ?? []).map((c) => ({ value: c }))}
|
||||||
|
filterOption={(input, opt) =>
|
||||||
|
(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())
|
||||||
|
}
|
||||||
|
placeholder="Авангард"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Form.Item name="description" label="Описание">
|
<Form.Item name="description" label="Описание">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
@ -260,7 +306,7 @@ export function ObjectsPage() {
|
||||||
<Divider orientation="left" plain>
|
<Divider orientation="left" plain>
|
||||||
Подключение к БД объекта{' '}
|
Подключение к БД объекта{' '}
|
||||||
<Typography.Text type="secondary" style={{ fontWeight: 400 }}>
|
<Typography.Text type="secondary" style={{ fontWeight: 400 }}>
|
||||||
(для синхронизации устройств)
|
(для синхронизации)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
|
|
|
||||||
304
frontend/src/pages/WorkDevices.tsx
Normal file
304
frontend/src/pages/WorkDevices.tsx
Normal file
|
|
@ -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<string[]>([])
|
||||||
|
const [filterCity, setFilterCity] = useState<string | undefined>()
|
||||||
|
const [filterClient, setFilterClient] = useState<string | undefined>()
|
||||||
|
const [filterObjectId, setFilterObjectId] = useState<number | undefined>()
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string | undefined>()
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
|
||||||
|
|
||||||
|
const [taskModal, setTaskModal] = useState(false)
|
||||||
|
const [selectedAction, setSelectedAction] = useState<string>('')
|
||||||
|
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) => <code>{ip}</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hostname',
|
||||||
|
dataIndex: 'hostname',
|
||||||
|
key: 'hostname',
|
||||||
|
render: (h: string | null) => h ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Категория',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
render: (c: string) => <CategoryTag category={c} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Объект',
|
||||||
|
dataIndex: 'object_id',
|
||||||
|
key: 'object_id',
|
||||||
|
render: (objId: number) => {
|
||||||
|
const obj = (allObjects ?? []).find((o) => o.id === objId)
|
||||||
|
if (!obj) return objId
|
||||||
|
return (
|
||||||
|
<Space size={4}>
|
||||||
|
{obj.city && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{obj.city}</Typography.Text>}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
onClick={() => navigate(`/work/objects/${objId}`)}
|
||||||
|
>
|
||||||
|
{obj.name}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Клиент',
|
||||||
|
key: 'client',
|
||||||
|
render: (_: unknown, row: DeviceItem) => {
|
||||||
|
const obj = (allObjects ?? []).find((o) => o.id === row.object_id)
|
||||||
|
return obj?.client ? <Tag>{obj.client}</Tag> : <Typography.Text type="secondary">—</Typography.Text>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Статус',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (s: string) => <DeviceStatusBadge status={s} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Последний ping',
|
||||||
|
dataIndex: 'last_seen',
|
||||||
|
key: 'last_seen',
|
||||||
|
render: (ts: string | null, row: DeviceItem) =>
|
||||||
|
ts ? (
|
||||||
|
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
|
||||||
|
<span>{row.last_ping_ms}ms</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : '—',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
Все устройства
|
||||||
|
</Typography.Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={openTaskModal}
|
||||||
|
disabled={!devices || devices.length === 0}
|
||||||
|
>
|
||||||
|
{selectedRowKeys.length > 0
|
||||||
|
? `Задача на выбранные (${selectedRowKeys.length})`
|
||||||
|
: 'Задача на выборку'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||||
|
<Col span={6}>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
placeholder="Категория"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
options={CATEGORY_OPTIONS}
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={setFilterCategory}
|
||||||
|
maxTagCount="responsive"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Select
|
||||||
|
placeholder="Город"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
|
||||||
|
value={filterCity}
|
||||||
|
onChange={setFilterCity}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Select
|
||||||
|
placeholder="Клиент"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
|
||||||
|
value={filterClient}
|
||||||
|
onChange={setFilterClient}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={6}>
|
||||||
|
<Select
|
||||||
|
placeholder="Объект"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={objectOptions}
|
||||||
|
value={filterObjectId}
|
||||||
|
onChange={setFilterObjectId}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Select
|
||||||
|
placeholder="Статус"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
allowClear
|
||||||
|
options={[
|
||||||
|
{ value: 'online', label: 'Online' },
|
||||||
|
{ value: 'offline', label: 'Offline' },
|
||||||
|
{ value: 'unknown', label: 'Unknown' },
|
||||||
|
]}
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={setFilterStatus}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{devices && (
|
||||||
|
<Typography.Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
|
||||||
|
Найдено: {devices.length} устройств
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={devices ?? []}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 50 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as number[]),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Task Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Создать задачу на выборку"
|
||||||
|
open={taskModal}
|
||||||
|
onCancel={() => setTaskModal(false)}
|
||||||
|
onOk={submitTask}
|
||||||
|
confirmLoading={createTask.isPending}
|
||||||
|
okText="Запустить"
|
||||||
|
>
|
||||||
|
{selectedRowKeys.length > 0 ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Выбрано устройств: {selectedRowKeys.length}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Задача будет запущена на все устройства из текущей выборки ({devices?.length ?? 0} шт.)
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="action" label="Действие" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="Выберите действие"
|
||||||
|
options={actions?.map((a) => ({ value: a.name, label: a.display_name }))}
|
||||||
|
onChange={setSelectedAction}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{currentAction?.params_schema.map((p) => (
|
||||||
|
<Form.Item key={p.name} name={p.name} label={p.label} rules={p.required ? [{ required: true }] : []}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
254
frontend/src/pages/WorkObjectDetail.tsx
Normal file
254
frontend/src/pages/WorkObjectDetail.tsx
Normal file
|
|
@ -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<string>('')
|
||||||
|
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 <Spin />
|
||||||
|
|
||||||
|
const deviceColumns = [
|
||||||
|
{
|
||||||
|
title: 'IP',
|
||||||
|
dataIndex: 'ip',
|
||||||
|
key: 'ip',
|
||||||
|
render: (ip: string) => <code>{ip}</code>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Hostname',
|
||||||
|
dataIndex: 'hostname',
|
||||||
|
key: 'hostname',
|
||||||
|
render: (h: string | null) => h ?? '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Стойка',
|
||||||
|
dataIndex: 'rack_name',
|
||||||
|
key: 'rack_name',
|
||||||
|
render: (name: string | null) =>
|
||||||
|
name ? <Tag color="blue">{name}</Tag> : <Typography.Text type="secondary">—</Typography.Text>,
|
||||||
|
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) => <CategoryTag category={c} />,
|
||||||
|
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) => <DeviceStatusBadge status={s} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Последний ping',
|
||||||
|
dataIndex: 'last_seen',
|
||||||
|
key: 'last_seen',
|
||||||
|
render: (ts: string | null, row: DeviceItem) =>
|
||||||
|
ts ? (
|
||||||
|
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
|
||||||
|
<span>{row.last_ping_ms}ms</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, row: DeviceItem) => (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => openTaskModal({ type: 'device', id: row.id }, `Задача: ${row.hostname ?? row.ip}`)}
|
||||||
|
>
|
||||||
|
Задача
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Breadcrumb: Работа → [City →] Object
|
||||||
|
const breadcrumbItems = [
|
||||||
|
{ title: <a onClick={() => navigate('/work/objects')}>Работа</a> },
|
||||||
|
...(obj?.city ? [{ title: <a onClick={() => navigate('/work/objects')}>{obj.city}</a> }] : []),
|
||||||
|
{ title: obj?.name },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
|
||||||
|
|
||||||
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
|
<Col flex="auto">
|
||||||
|
<Descriptions size="small" bordered column={3}>
|
||||||
|
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Клиент">{obj?.client ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={() => openTaskModal({ type: 'object', id: objId }, 'Задача на весь объект')}
|
||||||
|
>
|
||||||
|
Задача на объект
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
openTaskModal(
|
||||||
|
{ type: 'category', object_id: objId, category: 'main_server' },
|
||||||
|
'Задача: все серверы'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Все серверы
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
openTaskModal(
|
||||||
|
{ type: 'category', object_id: objId, category: 'bank_terminal' },
|
||||||
|
'Задача: все терминалы'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Все терминалы
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Поиск по IP или hostname..."
|
||||||
|
allowClear
|
||||||
|
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={filteredDevices}
|
||||||
|
columns={deviceColumns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={devLoading}
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 50 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Task Modal */}
|
||||||
|
<Modal
|
||||||
|
title={taskModal?.title ?? 'Создать задачу'}
|
||||||
|
open={!!taskModal}
|
||||||
|
onCancel={() => setTaskModal(null)}
|
||||||
|
onOk={submitTask}
|
||||||
|
confirmLoading={createTask.isPending}
|
||||||
|
okText="Запустить"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="action" label="Действие" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
placeholder="Выберите действие"
|
||||||
|
options={actions?.map((a) => ({ value: a.name, label: a.display_name }))}
|
||||||
|
onChange={setSelectedAction}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{currentAction?.params_schema.map((p) => (
|
||||||
|
<Form.Item key={p.name} name={p.name} label={p.label} rules={p.required ? [{ required: true }] : []}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue