Merge branch 'main' of https://git.ihateamerica.ru/dv/utp_service
This commit is contained in:
commit
d0c2054c43
20 changed files with 2231 additions and 136 deletions
|
|
@ -6,7 +6,14 @@
|
|||
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\")",
|
||||
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")",
|
||||
"Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")",
|
||||
"Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")"
|
||||
"Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(python -c \"import openpyxl; print\\(''openpyxl available:'', openpyxl.__version__\\)\")",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(python create_objects_template.py)",
|
||||
"Bash(python -c \":*)",
|
||||
"Bash(python -c \"import sys; data = sys.stdin.buffer.read\\(\\); print\\(data.decode\\(''''utf-8'''', errors=''''replace''''\\)\\)\")",
|
||||
"Bash(docker compose:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
backend/alembic/versions/0010_rack_type.py
Normal file
22
backend/alembic/versions/0010_rack_type.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""add rack_type to racks
|
||||
|
||||
Revision ID: 0010
|
||||
Revises: 0009
|
||||
Create Date: 2026-04-08
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("racks", sa.Column("rack_type", sa.String(50), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("racks", "rack_type")
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
|
@ -10,6 +11,12 @@ from app.routers import admin, alerts, auth, devices, events, global_devices, ob
|
|||
from app.workers.monitor import start_monitor, stop_monitor
|
||||
from app.workers.startup import recover_stale_tasks
|
||||
|
||||
# Configure logging to output to console
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ DEVICE_ROLES = ("plate", "face", "overview", "other")
|
|||
DEVICE_LOCATIONS = ("server_room", "street")
|
||||
|
||||
DEVICE_STATUSES = ("online", "offline", "unknown")
|
||||
DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync")
|
||||
DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync", "auto_ssh")
|
||||
|
||||
|
||||
class Device(Base, TimestampMixin):
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class Rack(Base, TimestampMixin):
|
|||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
rack_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -244,6 +244,18 @@ async def update_device(
|
|||
return device
|
||||
|
||||
|
||||
@router.delete("", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_all_devices(
|
||||
obj_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
await _get_object(db, obj_id)
|
||||
result = await db.execute(select(Device).where(Device.object_id == obj_id))
|
||||
for device in result.scalars().all():
|
||||
await db.delete(device)
|
||||
|
||||
|
||||
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_device(
|
||||
obj_id: int,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy import select, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
|
@ -9,11 +13,25 @@ from app.models.object import Object
|
|||
from app.models.tag import Tag
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import SheetsSyncRequest
|
||||
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult
|
||||
from app.schemas.object import (
|
||||
ObjectCreate,
|
||||
ObjectImportError,
|
||||
ObjectImportPreview,
|
||||
ObjectImportResult,
|
||||
ObjectImportRow,
|
||||
ObjectImportSkipped,
|
||||
ObjectMetaResponse,
|
||||
ObjectRead,
|
||||
ObjectUpdate,
|
||||
SyncResult,
|
||||
DiscoveryStarted,
|
||||
)
|
||||
from app.services import crypto
|
||||
from app.services.database import AsyncSessionLocal
|
||||
from app.services.device_discovery import discover_via_ssh
|
||||
from app.services.object_db import sync_from_object_db
|
||||
from app.services.sheets import sync_from_sheets
|
||||
from app.services.sse import sse_manager, SSEEvent
|
||||
|
||||
router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
|
||||
|
||||
|
|
@ -132,20 +150,26 @@ async def update_object(
|
|||
@router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_object(
|
||||
obj_id: int,
|
||||
force: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
obj = await _load_object(db, obj_id)
|
||||
|
||||
# Check for devices (RESTRICT constraint would catch this, but give a clear message)
|
||||
count_result = await db.execute(
|
||||
select(Device.id).where(Device.object_id == obj_id).limit(1)
|
||||
)
|
||||
if count_result.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot delete object with existing devices. Remove all devices first.",
|
||||
if force:
|
||||
devices_result = await db.execute(select(Device).where(Device.object_id == obj_id))
|
||||
for device in devices_result.scalars().all():
|
||||
await db.delete(device)
|
||||
await db.flush()
|
||||
else:
|
||||
count_result = await db.execute(
|
||||
select(Device.id).where(Device.object_id == obj_id).limit(1)
|
||||
)
|
||||
if count_result.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Cannot delete object with existing devices. Remove all devices first.",
|
||||
)
|
||||
await db.delete(obj)
|
||||
|
||||
|
||||
|
|
@ -165,21 +189,219 @@ async def sync_devices(
|
|||
)
|
||||
|
||||
|
||||
@router.post("/{obj_id}/discover", response_model=SyncResult)
|
||||
async def _run_discovery_background(obj_id: int, task_id: str) -> None:
|
||||
"""Run SSH discovery in background, emitting SSE progress events."""
|
||||
|
||||
async def emit(event_type: str, data: dict) -> None:
|
||||
await sse_manager.publish(SSEEvent(type=event_type, task_id=task_id, data=data))
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
async with session.begin():
|
||||
result_row = await session.execute(
|
||||
select(Object).options(selectinload(Object.tags)).where(Object.id == obj_id)
|
||||
)
|
||||
obj = result_row.scalar_one_or_none()
|
||||
if obj is None:
|
||||
await emit("disc_done", {"created": 0, "updated": 0, "skipped": 0, "errors": ["Object not found"]})
|
||||
return
|
||||
discovery_result = await discover_via_ssh(db=session, obj=obj, progress_cb=emit)
|
||||
await emit("disc_done", {
|
||||
"created": discovery_result.created,
|
||||
"updated": discovery_result.updated,
|
||||
"skipped": discovery_result.skipped,
|
||||
"errors": discovery_result.errors,
|
||||
})
|
||||
except Exception as exc:
|
||||
await emit("disc_done", {"created": 0, "updated": 0, "skipped": 0, "errors": [str(exc)]})
|
||||
|
||||
|
||||
@router.post("/{obj_id}/discover", response_model=DiscoveryStarted)
|
||||
async def discover_devices(
|
||||
obj_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
"""SSH into each rack PC, parse caps config, discover network devices."""
|
||||
obj = await _load_object(db, obj_id)
|
||||
result = await discover_via_ssh(db, obj)
|
||||
await db.flush()
|
||||
return SyncResult(
|
||||
created=result.created,
|
||||
updated=result.updated,
|
||||
skipped=result.skipped,
|
||||
errors=result.errors,
|
||||
await _load_object(db, obj_id)
|
||||
task_id = f"disc-{obj_id}"
|
||||
asyncio.create_task(_run_discovery_background(obj_id, task_id))
|
||||
return DiscoveryStarted(task_id=task_id)
|
||||
|
||||
|
||||
@router.post("/import/preview", response_model=ObjectImportPreview)
|
||||
async def preview_objects_import(
|
||||
file: Annotated[UploadFile, File()],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
"""Dry-run: parse xlsx and return what would be created/skipped."""
|
||||
return await _parse_objects_xlsx(db, file, dry_run=True)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ObjectImportResult, status_code=status.HTTP_201_CREATED)
|
||||
async def import_objects(
|
||||
file: Annotated[UploadFile, File()],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
):
|
||||
"""Import objects from xlsx. Deduplication by server_ip (skip + warn on duplicate)."""
|
||||
preview = await _parse_objects_xlsx(db, file, dry_run=False)
|
||||
return ObjectImportResult(
|
||||
created=len(preview.would_create),
|
||||
skipped=len(preview.would_skip),
|
||||
errors=preview.errors,
|
||||
)
|
||||
|
||||
|
||||
def _norm_col(name: str) -> str:
|
||||
"""Normalize xlsx header: 'server_ip *' → 'server_ip', 'ssh_user (root)' → 'ssh_user'."""
|
||||
name = re.sub(r"\s*\*", "", name)
|
||||
name = re.sub(r"\s*\(.*?\)", "", name)
|
||||
return name.strip().lower()
|
||||
|
||||
|
||||
async def _parse_objects_xlsx(
|
||||
db: AsyncSession, file: UploadFile, dry_run: bool
|
||||
) -> ObjectImportPreview:
|
||||
import io
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=500, detail="openpyxl not installed")
|
||||
|
||||
content = await file.read()
|
||||
try:
|
||||
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Не удалось открыть файл. Убедитесь, что файл в формате .xlsx")
|
||||
|
||||
# Use first sheet (the data sheet)
|
||||
ws = wb.worksheets[0]
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
raise HTTPException(status_code=400, detail="Файл пустой")
|
||||
|
||||
# Build column index from header row
|
||||
header = [_norm_col(str(c)) if c is not None else "" for c in rows[0]]
|
||||
col = {name: idx for idx, name in enumerate(header) if name}
|
||||
|
||||
required = {"name", "city", "server_ip"}
|
||||
missing = required - col.keys()
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Отсутствуют обязательные колонки: {', '.join(sorted(missing))}",
|
||||
)
|
||||
|
||||
def _cell(row: tuple, key: str) -> str | None:
|
||||
idx = col.get(key)
|
||||
if idx is None or idx >= len(row):
|
||||
return None
|
||||
val = row[idx]
|
||||
if val is None:
|
||||
return None
|
||||
s = str(val).strip()
|
||||
return s if s else None
|
||||
|
||||
would_create: list[ObjectImportRow] = []
|
||||
would_skip: list[ObjectImportSkipped] = []
|
||||
errors: list[ObjectImportError] = []
|
||||
|
||||
# Track IPs seen within this file to catch intra-file duplicates
|
||||
seen_ips: dict[str, int] = {}
|
||||
|
||||
# Pre-load existing server_ips from DB for fast lookup
|
||||
existing_ips_result = await db.execute(
|
||||
select(Object.server_ip, Object.name).where(Object.server_ip.isnot(None))
|
||||
)
|
||||
existing_ips: dict[str, str] = {ip: name for ip, name in existing_ips_result.all()}
|
||||
|
||||
for row_num, row in enumerate(rows[1:], start=2):
|
||||
# Skip entirely blank rows
|
||||
if all(c is None or str(c).strip() == "" for c in row):
|
||||
continue
|
||||
|
||||
name = _cell(row, "name")
|
||||
city = _cell(row, "city")
|
||||
server_ip = _cell(row, "server_ip")
|
||||
|
||||
# Validate required fields
|
||||
missing_fields = []
|
||||
if not name:
|
||||
missing_fields.append("name")
|
||||
if not city:
|
||||
missing_fields.append("city")
|
||||
if not server_ip:
|
||||
missing_fields.append("server_ip")
|
||||
if missing_fields:
|
||||
errors.append(ObjectImportError(
|
||||
row=row_num,
|
||||
reason=f"Обязательные поля не заполнены: {', '.join(missing_fields)}",
|
||||
))
|
||||
continue
|
||||
|
||||
# Deduplicate within file
|
||||
if server_ip in seen_ips:
|
||||
errors.append(ObjectImportError(
|
||||
row=row_num,
|
||||
reason=f"Дублирующийся IP {server_ip} в файле (строка {seen_ips[server_ip]})",
|
||||
))
|
||||
continue
|
||||
seen_ips[server_ip] = row_num
|
||||
|
||||
# Deduplicate against DB
|
||||
if server_ip in existing_ips:
|
||||
would_skip.append(ObjectImportSkipped(
|
||||
row=row_num,
|
||||
name=name,
|
||||
server_ip=server_ip,
|
||||
reason=f"Объект с IP {server_ip} уже существует: «{existing_ips[server_ip]}»",
|
||||
))
|
||||
continue
|
||||
|
||||
ssh_user = _cell(row, "ssh_user") or "root"
|
||||
ssh_password = _cell(row, "ssh_password") or "123123"
|
||||
db_via_tunnel_raw = _cell(row, "db_via_tunnel")
|
||||
db_via_tunnel = bool(int(db_via_tunnel_raw)) if db_via_tunnel_raw and db_via_tunnel_raw.isdigit() else False
|
||||
|
||||
import_row = ObjectImportRow(
|
||||
row=row_num,
|
||||
name=name,
|
||||
city=city,
|
||||
server_ip=server_ip,
|
||||
client=_cell(row, "client"),
|
||||
description=_cell(row, "description"),
|
||||
notes=_cell(row, "notes"),
|
||||
ssh_user=ssh_user,
|
||||
db_via_tunnel=db_via_tunnel,
|
||||
)
|
||||
would_create.append(import_row)
|
||||
|
||||
if not dry_run:
|
||||
obj = Object(
|
||||
name=name,
|
||||
city=city,
|
||||
server_ip=server_ip,
|
||||
client=import_row.client,
|
||||
description=import_row.description,
|
||||
notes=import_row.notes,
|
||||
ssh_user=ssh_user,
|
||||
db_via_tunnel=db_via_tunnel,
|
||||
is_active=True,
|
||||
)
|
||||
obj.ssh_pass_enc = crypto.encrypt(ssh_password)
|
||||
db.add(obj)
|
||||
# update local cache so next rows in this file see the newly added IP
|
||||
existing_ips[server_ip] = name
|
||||
|
||||
if not dry_run:
|
||||
await db.flush()
|
||||
|
||||
return ObjectImportPreview(
|
||||
would_create=would_create,
|
||||
would_skip=would_skip,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class DeviceRead(BaseModel):
|
|||
external_id: Optional[str] = None
|
||||
rack_id: Optional[int] = None
|
||||
rack_name: Optional[str] = None
|
||||
rack_type: Optional[str] = None
|
||||
ssh_user_override: Optional[str] = None
|
||||
ssh_port_override: Optional[int] = None
|
||||
device_meta: dict[str, Any] = {}
|
||||
|
|
@ -91,6 +92,7 @@ class DeviceRead(BaseModel):
|
|||
obj = cls.model_validate(device)
|
||||
if device.rack is not None:
|
||||
obj.rack_name = device.rack.name
|
||||
obj.rack_type = device.rack.rack_type
|
||||
return obj
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -85,8 +85,50 @@ class ObjectMetaResponse(BaseModel):
|
|||
clients: list[str]
|
||||
|
||||
|
||||
# --- Import from xlsx ---
|
||||
|
||||
class ObjectImportRow(BaseModel):
|
||||
row: int
|
||||
name: str
|
||||
city: str
|
||||
server_ip: str
|
||||
client: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
ssh_user: str
|
||||
db_via_tunnel: bool
|
||||
|
||||
|
||||
class ObjectImportSkipped(BaseModel):
|
||||
row: int
|
||||
name: str
|
||||
server_ip: str
|
||||
reason: str
|
||||
|
||||
|
||||
class ObjectImportError(BaseModel):
|
||||
row: int
|
||||
reason: str
|
||||
|
||||
|
||||
class ObjectImportPreview(BaseModel):
|
||||
would_create: list[ObjectImportRow]
|
||||
would_skip: list[ObjectImportSkipped]
|
||||
errors: list[ObjectImportError]
|
||||
|
||||
|
||||
class ObjectImportResult(BaseModel):
|
||||
created: int
|
||||
skipped: int
|
||||
errors: list[ObjectImportError]
|
||||
|
||||
|
||||
class SyncResult(BaseModel):
|
||||
created: int
|
||||
updated: int
|
||||
skipped: int
|
||||
errors: list[str] = []
|
||||
|
||||
|
||||
class DiscoveryStarted(BaseModel):
|
||||
task_id: str
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||
|
||||
class RackCreate(BaseModel):
|
||||
name: str
|
||||
rack_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
zone_id: Optional[int] = None
|
||||
|
|
@ -13,6 +14,7 @@ class RackCreate(BaseModel):
|
|||
|
||||
class RackUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
rack_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
zone_id: Optional[int] = None
|
||||
|
|
@ -22,6 +24,7 @@ class RackRead(BaseModel):
|
|||
id: int
|
||||
object_id: int
|
||||
name: str
|
||||
rack_type: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
zone_id: Optional[int] = None
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -21,6 +21,7 @@ dependencies = [
|
|||
# Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var)
|
||||
"gspread>=6.0",
|
||||
"google-auth>=2.0",
|
||||
"openpyxl>=3.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
129
create_objects_template.py
Normal file
129
create_objects_template.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Импорт объектов"
|
||||
|
||||
DARK_BLUE = "1F3864"
|
||||
LIGHT_GRAY = "F2F2F2"
|
||||
LIGHT_BLUE = "EBF3FB"
|
||||
WHITE = "FFFFFF"
|
||||
|
||||
columns = [
|
||||
("name *", 30),
|
||||
("city *", 20),
|
||||
("server_ip *", 18),
|
||||
("client", 25),
|
||||
("description", 35),
|
||||
("notes", 35),
|
||||
("ssh_user (root)", 15),
|
||||
("ssh_password (123123)", 18),
|
||||
("db_via_tunnel (0)", 20),
|
||||
]
|
||||
|
||||
sample_row = [
|
||||
"Парковка ТЦ Мега",
|
||||
"Москва",
|
||||
"192.168.1.10",
|
||||
"ООО Рога и Копыта",
|
||||
"Въезд с ул. Ленина",
|
||||
"Контакт: +7 999 000-00-00",
|
||||
"admin",
|
||||
"qwerty123",
|
||||
0,
|
||||
]
|
||||
|
||||
header_font = Font(name="Arial", bold=True, color=WHITE, size=11)
|
||||
header_fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE)
|
||||
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
sample_fill = PatternFill("solid", start_color=LIGHT_GRAY, fgColor=LIGHT_GRAY)
|
||||
blue_fill = PatternFill("solid", start_color=LIGHT_BLUE, fgColor=LIGHT_BLUE)
|
||||
white_fill = PatternFill("solid", start_color=WHITE, fgColor=WHITE)
|
||||
data_font = Font(name="Arial", size=10)
|
||||
data_align = Alignment(vertical="center")
|
||||
|
||||
# Header row
|
||||
for col_idx, (header, width) in enumerate(columns, start=1):
|
||||
cell = ws.cell(row=1, column=col_idx, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_align
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
||||
|
||||
ws.row_dimensions[1].height = 30
|
||||
|
||||
# Sample data row (row 2)
|
||||
for col_idx, value in enumerate(sample_row, start=1):
|
||||
cell = ws.cell(row=2, column=col_idx, value=value)
|
||||
cell.font = data_font
|
||||
cell.fill = sample_fill
|
||||
cell.alignment = data_align
|
||||
|
||||
# Empty data rows 3-52, alternating white / light blue
|
||||
for row in range(3, 53):
|
||||
fill = white_fill if (row % 2 == 1) else blue_fill
|
||||
for col_idx in range(1, len(columns) + 1):
|
||||
cell = ws.cell(row=row, column=col_idx)
|
||||
cell.font = data_font
|
||||
cell.fill = fill
|
||||
cell.alignment = data_align
|
||||
|
||||
ws.freeze_panes = "A2"
|
||||
|
||||
# --- Справка sheet ---
|
||||
ws2 = wb.create_sheet("Справка")
|
||||
|
||||
ws2.column_dimensions["A"].width = 22
|
||||
ws2.column_dimensions["B"].width = 40
|
||||
ws2.column_dimensions["C"].width = 20
|
||||
ws2.column_dimensions["D"].width = 20
|
||||
|
||||
title_cell = ws2.cell(row=1, column=1, value="Инструкция по заполнению")
|
||||
title_cell.font = Font(name="Arial", bold=True, size=13)
|
||||
ws2.row_dimensions[1].height = 24
|
||||
|
||||
# Sub-header
|
||||
headers_ref = ["Колонка", "Описание", "Обязательность", "Значение по умолчанию"]
|
||||
for col_idx, h in enumerate(headers_ref, start=1):
|
||||
cell = ws2.cell(row=3, column=col_idx, value=h)
|
||||
cell.font = Font(name="Arial", bold=True, color=WHITE, size=11)
|
||||
cell.fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE)
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws2.row_dimensions[3].height = 22
|
||||
|
||||
ref_rows = [
|
||||
("name", "Название объекта (парковки, площадки)", "Обязательно", "—"),
|
||||
("city", "Город расположения объекта", "Обязательно", "—"),
|
||||
("server_ip", "IP-адрес основного сервера CAPS", "Обязательно", "—"),
|
||||
("client", "Клиент / заказчик", "Необязательно","—"),
|
||||
("description", "Описание объекта", "Необязательно","—"),
|
||||
("notes", "Заметки (контакты, особенности и т.п.)", "Необязательно","—"),
|
||||
("ssh_user", "Логин для SSH-подключения к серверу объекта", "Необязательно","root"),
|
||||
("ssh_password", "Пароль для SSH-подключения к серверу объекта", "Необязательно","123123"),
|
||||
("db_via_tunnel", "Подключаться к БД CAPS через SSH-туннель (0 — нет, 1 — да)","Необязательно","0"),
|
||||
]
|
||||
|
||||
for i, (col_name, desc, required, default) in enumerate(ref_rows, start=4):
|
||||
fill = white_fill if (i % 2 == 0) else blue_fill
|
||||
values = [col_name, desc, required, default]
|
||||
for col_idx, val in enumerate(values, start=1):
|
||||
cell = ws2.cell(row=i, column=col_idx, value=val)
|
||||
cell.font = Font(name="Arial", size=10)
|
||||
cell.fill = fill
|
||||
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
ws2.row_dimensions[i].height = 18
|
||||
|
||||
note_row = len(ref_rows) + 5
|
||||
note_cell = ws2.cell(row=note_row, column=1,
|
||||
value="⚠ Дедупликация по полю server_ip: если объект с таким IP уже существует в системе, строка будет пропущена с предупреждением.")
|
||||
note_cell.font = Font(name="Arial", size=10, bold=True, color="C00000")
|
||||
note_cell.alignment = Alignment(wrap_text=True)
|
||||
ws2.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=4)
|
||||
ws2.row_dimensions[note_row].height = 32
|
||||
|
||||
out_path = r"C:\Users\Professional\Documents\VSCode\utp_service\objects_import_template.xlsx"
|
||||
wb.save(out_path)
|
||||
print(f"Saved: {out_path}")
|
||||
|
|
@ -32,6 +32,7 @@ export interface DeviceItem {
|
|||
source: string
|
||||
rack_id: number | null
|
||||
rack_name: string | null
|
||||
rack_type: string | null
|
||||
ssh_user_override: string | null
|
||||
ssh_port_override: number | null
|
||||
status: string
|
||||
|
|
@ -130,6 +131,22 @@ export const useDeleteObject = () => {
|
|||
})
|
||||
}
|
||||
|
||||
export const useForceDeleteObject = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/api/v1/objects/${id}`, { params: { force: true } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteAllDevices = (objId: number) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => api.delete(`/api/v1/objects/${objId}/devices`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
|
||||
})
|
||||
}
|
||||
|
||||
export interface DeviceCreatePayload {
|
||||
ip: string
|
||||
hostname?: string
|
||||
|
|
@ -204,15 +221,16 @@ export const useSyncObject = (objId: number) => {
|
|||
})
|
||||
}
|
||||
|
||||
export const useDiscoverObject = (objId: number) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<SyncResult>(`/api/v1/objects/${objId}/discover`).then((r) => r.data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
|
||||
})
|
||||
export interface DiscoveryStarted {
|
||||
task_id: string
|
||||
}
|
||||
|
||||
export const useDiscoverObject = (objId: number) =>
|
||||
useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<DiscoveryStarted>(`/api/v1/objects/${objId}/discover`).then((r) => r.data),
|
||||
})
|
||||
|
||||
export interface SheetsSyncPayload {
|
||||
spreadsheet_id: string
|
||||
sheet_name?: string
|
||||
|
|
@ -243,6 +261,69 @@ export const useObjectsMeta = () =>
|
|||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
// --- Object xlsx import ---
|
||||
|
||||
export interface ObjectImportRow {
|
||||
row: number
|
||||
name: string
|
||||
city: string
|
||||
server_ip: string
|
||||
client: string | null
|
||||
description: string | null
|
||||
notes: string | null
|
||||
ssh_user: string
|
||||
db_via_tunnel: boolean
|
||||
}
|
||||
|
||||
export interface ObjectImportSkipped {
|
||||
row: number
|
||||
name: string
|
||||
server_ip: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface ObjectImportError {
|
||||
row: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface ObjectImportPreview {
|
||||
would_create: ObjectImportRow[]
|
||||
would_skip: ObjectImportSkipped[]
|
||||
errors: ObjectImportError[]
|
||||
}
|
||||
|
||||
export interface ObjectImportResult {
|
||||
created: number
|
||||
skipped: number
|
||||
errors: ObjectImportError[]
|
||||
}
|
||||
|
||||
export const usePreviewObjectsXLSX = () =>
|
||||
useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api
|
||||
.post<ObjectImportPreview>('/api/v1/objects/import/preview', form)
|
||||
.then((r) => r.data)
|
||||
},
|
||||
})
|
||||
|
||||
export const useImportObjectsXLSX = () => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api
|
||||
.post<ObjectImportResult>('/api/v1/objects/import', form)
|
||||
.then((r) => r.data)
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useSyncSheets = (objId: number) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export interface RackItem {
|
|||
id: number
|
||||
object_id: number
|
||||
name: string
|
||||
rack_type: string | null
|
||||
description: string | null
|
||||
location: string | null
|
||||
zone_id: number | null
|
||||
|
|
@ -22,7 +23,7 @@ export const useRacks = (objId: number) =>
|
|||
export const useCreateRack = (objId: number) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { name: string; description?: string; location?: string; zone_id?: number | null }) =>
|
||||
mutationFn: (body: { name: string; rack_type?: string; description?: string; location?: string; zone_id?: number | null }) =>
|
||||
api.post<RackItem>(`/api/v1/objects/${objId}/racks`, body).then((r) => r.data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }),
|
||||
})
|
||||
|
|
|
|||
307
frontend/src/components/DiscoveryDrawer.tsx
Normal file
307
frontend/src/components/DiscoveryDrawer.tsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
LoadingOutlined,
|
||||
MinusCircleOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Drawer, Progress, Space, Tag, Typography } from 'antd'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
|
||||
interface RackStatus {
|
||||
rack_name: string
|
||||
rack_host: string
|
||||
state: 'pending' | 'running' | 'done' | 'error'
|
||||
found: number
|
||||
ssh_ok: boolean
|
||||
}
|
||||
|
||||
interface SlaveItem {
|
||||
rack_name: string
|
||||
ip: string
|
||||
found: number
|
||||
}
|
||||
|
||||
interface InfraItem {
|
||||
type: 'mikrotik' | 'proxmox'
|
||||
ip: string
|
||||
}
|
||||
|
||||
type PhaseState = 'idle' | 'running' | 'done'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
objectName: string
|
||||
taskId: string | null
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function DiscoveryDrawer({ open, onClose, objectName, taskId, onComplete }: Props) {
|
||||
const accessToken = useAuthStore((s) => s.accessToken)
|
||||
const [phase, setPhase] = useState<'connecting' | 'racks' | 'slaves' | 'infra' | 'done'>('connecting')
|
||||
const [currentAction, setCurrentAction] = useState<string | null>(null)
|
||||
const [racks, setRacks] = useState<RackStatus[]>([])
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [slaves, setSlaves] = useState<SlaveItem[]>([])
|
||||
const [slavesPhase, setSlavesPhase] = useState<PhaseState>('idle')
|
||||
const [mikrotikPhase, setMikrotikPhase] = useState<PhaseState>('idle')
|
||||
const [proxmoxPhase, setProxmoxPhase] = useState<PhaseState>('idle')
|
||||
const [infraFound, setInfraFound] = useState<InfraItem[]>([])
|
||||
const [result, setResult] = useState<{ created: number; updated: number; skipped: number; errors: string[] } | null>(null)
|
||||
const ctrlRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !taskId || !accessToken) return
|
||||
|
||||
setPhase('connecting')
|
||||
setCurrentAction(null)
|
||||
setRacks([])
|
||||
setCurrent(0)
|
||||
setTotal(0)
|
||||
setSlaves([])
|
||||
setSlavesPhase('idle')
|
||||
setMikrotikPhase('idle')
|
||||
setProxmoxPhase('idle')
|
||||
setInfraFound([])
|
||||
setResult(null)
|
||||
|
||||
const ctrl = new AbortController()
|
||||
ctrlRef.current = ctrl
|
||||
|
||||
fetchEventSource(`/api/v1/events?task_id=${taskId}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
signal: ctrl.signal,
|
||||
onmessage(ev) {
|
||||
try {
|
||||
const data = JSON.parse(ev.data)
|
||||
|
||||
if (ev.event === 'disc_action') {
|
||||
setCurrentAction(data.message)
|
||||
|
||||
} else if (ev.event === 'disc_server') {
|
||||
setTotal(data.rack_count)
|
||||
setPhase('racks')
|
||||
|
||||
} else if (ev.event === 'disc_rack_start') {
|
||||
setTotal(data.total)
|
||||
setCurrent(data.index)
|
||||
setRacks((prev) => {
|
||||
if (prev.find((r) => r.rack_name === data.rack_name)) {
|
||||
return prev.map((r) =>
|
||||
r.rack_name === data.rack_name ? { ...r, state: 'running' } : r
|
||||
)
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ rack_name: data.rack_name, rack_host: data.rack_host, state: 'running', found: 0, ssh_ok: false },
|
||||
]
|
||||
})
|
||||
|
||||
} else if (ev.event === 'disc_rack_done') {
|
||||
setCurrent((c) => c + 1)
|
||||
setRacks((prev) =>
|
||||
prev.map((r) =>
|
||||
r.rack_name === data.rack_name
|
||||
? { ...r, state: data.ssh_ok ? 'done' : 'error', found: data.found ?? 0, ssh_ok: data.ssh_ok }
|
||||
: r
|
||||
)
|
||||
)
|
||||
|
||||
} else if (ev.event === 'disc_phase') {
|
||||
if (data.phase === 'slaves') {
|
||||
setPhase('slaves')
|
||||
setSlavesPhase('running')
|
||||
} else if (data.phase === 'mikrotik') {
|
||||
setPhase('infra')
|
||||
setSlavesPhase((s) => s === 'running' ? 'done' : s)
|
||||
setMikrotikPhase('running')
|
||||
} else if (data.phase === 'proxmox') {
|
||||
setMikrotikPhase((s) => s === 'running' ? 'done' : s)
|
||||
setProxmoxPhase('running')
|
||||
}
|
||||
|
||||
} else if (ev.event === 'disc_slave_found') {
|
||||
setSlaves((prev) => [...prev, { rack_name: data.rack_name, ip: data.ip, found: data.found }])
|
||||
|
||||
} else if (ev.event === 'disc_infra_found') {
|
||||
setInfraFound((prev) => [...prev, { type: data.type, ip: data.ip }])
|
||||
|
||||
} else if (ev.event === 'disc_done') {
|
||||
setResult({
|
||||
created: data.created ?? 0,
|
||||
updated: data.updated ?? 0,
|
||||
skipped: data.skipped ?? 0,
|
||||
errors: data.errors ?? [],
|
||||
})
|
||||
setSlavesPhase((s) => s === 'running' ? 'done' : s)
|
||||
setMikrotikPhase((s) => s === 'running' ? 'done' : s)
|
||||
setProxmoxPhase((s) => s === 'running' ? 'done' : s)
|
||||
setPhase('done')
|
||||
setCurrentAction(null)
|
||||
ctrl.abort()
|
||||
onComplete?.()
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
},
|
||||
onerror() {
|
||||
ctrl.abort()
|
||||
},
|
||||
})
|
||||
|
||||
return () => ctrl.abort()
|
||||
}, [open, taskId, accessToken])
|
||||
|
||||
const percent = total > 0 ? Math.round((current / total) * 100) : 0
|
||||
|
||||
const rackIcon = (state: RackStatus['state']) => {
|
||||
if (state === 'running') return <LoadingOutlined style={{ color: '#1677ff' }} />
|
||||
if (state === 'done') return <CheckCircleOutlined style={{ color: '#52c41a' }} />
|
||||
if (state === 'error') return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />
|
||||
return <MinusCircleOutlined style={{ color: '#d9d9d9' }} />
|
||||
}
|
||||
|
||||
const phaseIcon = (state: PhaseState) => {
|
||||
if (state === 'running') return <LoadingOutlined style={{ color: '#1677ff', marginRight: 6 }} />
|
||||
if (state === 'done') return <CheckCircleOutlined style={{ color: '#52c41a', marginRight: 6 }} />
|
||||
return <MinusCircleOutlined style={{ color: '#d9d9d9', marginRight: 6 }} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`SSH Discovery — ${objectName}`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={440}
|
||||
footer={null}
|
||||
>
|
||||
{/* Current action */}
|
||||
{(phase === 'connecting' || currentAction) && (
|
||||
<div style={{ marginBottom: 12, minHeight: 20 }}>
|
||||
{phase === 'connecting' && !currentAction ? (
|
||||
<Space>
|
||||
<LoadingOutlined />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>Запуск discovery...</Typography.Text>
|
||||
</Space>
|
||||
) : currentAction ? (
|
||||
<Space>
|
||||
<LoadingOutlined style={{ fontSize: 11, color: '#8c8c8c' }} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{currentAction}</Typography.Text>
|
||||
</Space>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rack progress bar */}
|
||||
{(phase === 'racks' || phase === 'slaves' || phase === 'infra' || phase === 'done') && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Стойки: {Math.min(current, total)} / {total}
|
||||
</Typography.Text>
|
||||
<Progress
|
||||
percent={phase === 'done' || current >= total ? 100 : percent}
|
||||
status={phase === 'done' ? 'success' : 'active'}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rack list */}
|
||||
{racks.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{racks.map((r) => (
|
||||
<div
|
||||
key={r.rack_name}
|
||||
style={{ display: 'flex', alignItems: 'center', padding: '4px 0', gap: 8, borderBottom: '1px solid #f5f5f5' }}
|
||||
>
|
||||
{rackIcon(r.state)}
|
||||
<Typography.Text style={{ flex: 1, fontSize: 13 }}>{r.rack_name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{r.rack_host}</Typography.Text>
|
||||
{r.state === 'done' && (
|
||||
<Tag color="blue" style={{ marginLeft: 4, fontSize: 11 }}>{r.found} уст.</Tag>
|
||||
)}
|
||||
{r.state === 'error' && <Tag color="red" style={{ fontSize: 11 }}>SSH ошибка</Tag>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slave phase */}
|
||||
{slavesPhase !== 'idle' && (
|
||||
<div style={{ marginBottom: 12, padding: '8px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: slaves.length > 0 ? 6 : 0 }}>
|
||||
{phaseIcon(slavesPhase)}
|
||||
<Typography.Text style={{ fontSize: 13 }}>
|
||||
Слейв-стойки
|
||||
{slaves.length > 0 && <Tag color="purple" style={{ marginLeft: 8 }}>{slaves.length} найдено</Tag>}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{slaves.map((s) => (
|
||||
<div key={s.rack_name} style={{ display: 'flex', alignItems: 'center', gap: 8, paddingLeft: 22, paddingBottom: 4 }}>
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 11 }} />
|
||||
<Typography.Text style={{ fontSize: 12 }}>{s.rack_name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{s.ip}</Typography.Text>
|
||||
<Tag color="blue" style={{ fontSize: 11 }}>{s.found} уст.</Tag>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MikroTik phase */}
|
||||
{mikrotikPhase !== 'idle' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
{phaseIcon(mikrotikPhase)}
|
||||
<Typography.Text style={{ fontSize: 13, flex: 1 }}>MikroTik (SSH_CLIENT)</Typography.Text>
|
||||
{infraFound.filter((i) => i.type === 'mikrotik').map((i) => (
|
||||
<Tag key={i.ip} color="orange" style={{ fontSize: 11 }}>{i.ip}</Tag>
|
||||
))}
|
||||
{mikrotikPhase === 'done' && infraFound.filter((i) => i.type === 'mikrotik').length === 0 && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>не найден</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxmox phase */}
|
||||
{proxmoxPhase !== 'idle' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '6px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
{phaseIcon(proxmoxPhase)}
|
||||
<Typography.Text style={{ fontSize: 13, flex: 1 }}>Proxmox (порт 8006)</Typography.Text>
|
||||
{infraFound.filter((i) => i.type === 'proxmox').map((i) => (
|
||||
<Tag key={i.ip} color="cyan" style={{ fontSize: 11 }}>{i.ip}</Tag>
|
||||
))}
|
||||
{proxmoxPhase === 'done' && infraFound.filter((i) => i.type === 'proxmox').length === 0 && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>не найдено</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final result */}
|
||||
{phase === 'done' && result && (
|
||||
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 8 }}>
|
||||
<Space wrap>
|
||||
<Tag color="green">+{result.created} добавлено</Tag>
|
||||
<Tag color="blue">{result.updated} обновлено</Tag>
|
||||
<Tag>{result.skipped} без изменений</Tag>
|
||||
</Space>
|
||||
{result.errors.length > 0 && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{result.errors.map((e, i) => (
|
||||
<Typography.Text
|
||||
key={i}
|
||||
type="danger"
|
||||
style={{ display: 'block', fontSize: 12, marginBottom: 4 }}
|
||||
>
|
||||
⚠ {e}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
|
@ -35,10 +35,13 @@ import {
|
|||
} from 'antd'
|
||||
import type { UploadFile } from 'antd'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
useCreateDevice,
|
||||
useDeleteDevice,
|
||||
useDeleteAllDevices,
|
||||
useForceDeleteObject,
|
||||
useDiscoverObject,
|
||||
useUpdateDevice,
|
||||
useDevices,
|
||||
|
|
@ -50,12 +53,23 @@ import {
|
|||
type DeviceImportPreview,
|
||||
type DeviceItem,
|
||||
} from '../api/objects'
|
||||
import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks'
|
||||
import { useRacks, useCreateRack, useUpdateRack, useDeleteRack, type RackItem } from '../api/racks'
|
||||
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
|
||||
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
|
||||
import { DiscoveryDrawer } from '../components/DiscoveryDrawer'
|
||||
import { stripEmpty } from '../utils/form'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const SERVER_CATEGORIES = new Set(['main_server', 'vm', 'router'])
|
||||
|
||||
const rackLabel = (name: string, type: string | null | undefined): string =>
|
||||
type ? `${name} ${type}` : name
|
||||
const DEVICE_COL_SPAN = 8
|
||||
|
||||
type GroupRow = { _type: 'group'; _key: string; label: string; count: number; onlineCount: number }
|
||||
type DeviceRow = DeviceItem & { _type: 'device' }
|
||||
type TableRow = GroupRow | DeviceRow
|
||||
|
||||
const DEVICE_CATEGORIES = [
|
||||
{ value: 'main_server', label: 'Главный сервер' },
|
||||
{ value: 'vm', label: 'Виртуалка / сервис' },
|
||||
|
|
@ -84,6 +98,7 @@ export function InventoryObjectDetailPage() {
|
|||
const { id } = useParams<{ id: string }>()
|
||||
const objId = Number(id)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: obj, isLoading: objLoading } = useObject(objId)
|
||||
const { data: devices, isLoading: devLoading } = useDevices(objId)
|
||||
|
|
@ -98,15 +113,21 @@ export function InventoryObjectDetailPage() {
|
|||
const previewCSV = usePreviewCSV(objId)
|
||||
const importCSV = useImportCSV(objId)
|
||||
const createRack = useCreateRack(objId)
|
||||
const updateRack = useUpdateRack(objId)
|
||||
const deleteRack = useDeleteRack(objId)
|
||||
const createZone = useCreateZone(objId)
|
||||
const deleteZone = useDeleteZone(objId)
|
||||
const deleteAllDevices = useDeleteAllDevices(objId)
|
||||
const forceDeleteObject = useForceDeleteObject()
|
||||
|
||||
const [deviceModal, setDeviceModal] = useState(false)
|
||||
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
|
||||
const [csvModal, setCsvModal] = useState(false)
|
||||
const [rackModal, setRackModal] = useState(false)
|
||||
const [sheetsModal, setSheetsModal] = useState(false)
|
||||
const [discoveryTaskId, setDiscoveryTaskId] = useState<string | null>(null)
|
||||
const [discoveryOpen, setDiscoveryOpen] = useState(false)
|
||||
const [editRack, setEditRack] = useState<RackItem | null>(null)
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null)
|
||||
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
|
@ -115,6 +136,7 @@ export function InventoryObjectDetailPage() {
|
|||
const [rackForm] = Form.useForm()
|
||||
const [zoneForm] = Form.useForm()
|
||||
const [sheetsForm] = Form.useForm()
|
||||
const [editRackForm] = Form.useForm()
|
||||
|
||||
const rackOptions = useMemo(
|
||||
() => (racks ?? []).map((r) => ({ value: r.id, label: r.name })),
|
||||
|
|
@ -132,18 +154,18 @@ export function InventoryObjectDetailPage() {
|
|||
|
||||
const handleDiscover = async () => {
|
||||
try {
|
||||
const result = await discoverMutation.mutateAsync()
|
||||
const msg = `SSH Discovery: +${result.created} найдено, ${result.updated} обновлено`
|
||||
if (result.errors.length > 0) {
|
||||
message.warning(`${msg}. Ошибки: ${result.errors.join('; ')}`)
|
||||
} else {
|
||||
message.success(msg)
|
||||
}
|
||||
const { task_id } = await discoverMutation.mutateAsync()
|
||||
setDiscoveryTaskId(task_id)
|
||||
setDiscoveryOpen(true)
|
||||
} catch {
|
||||
message.error('Ошибка SSH discovery')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscoveryComplete = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices', objId] })
|
||||
}
|
||||
|
||||
const handleSyncSheets = async () => {
|
||||
try {
|
||||
const values = await sheetsForm.validateFields()
|
||||
|
|
@ -217,6 +239,34 @@ export function InventoryObjectDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleDeleteAllDevices = async () => {
|
||||
try {
|
||||
await deleteAllDevices.mutateAsync()
|
||||
message.success('Все устройства удалены')
|
||||
} catch {
|
||||
message.error('Ошибка при удалении устройств')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteObject = () => {
|
||||
Modal.confirm({
|
||||
title: `Удалить объект «${obj?.name}»?`,
|
||||
content: `Будут удалены все устройства (${devices?.length ?? 0} шт.) и сам объект. Это действие необратимо.`,
|
||||
okText: 'Удалить',
|
||||
cancelText: 'Отмена',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await forceDeleteObject.mutateAsync(objId)
|
||||
message.success('Объект удалён')
|
||||
navigate('/inventory/objects')
|
||||
} catch {
|
||||
message.error('Ошибка при удалении объекта')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCsvPreview = async () => {
|
||||
if (!csvFile) return
|
||||
try {
|
||||
|
|
@ -268,6 +318,26 @@ export function InventoryObjectDetailPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const openEditRack = (row: RackItem) => {
|
||||
editRackForm.setFieldsValue({
|
||||
rack_type: row.rack_type ?? '',
|
||||
location: row.location ?? '',
|
||||
})
|
||||
setEditRack(row)
|
||||
}
|
||||
|
||||
const handleSaveRack = async () => {
|
||||
if (!editRack) return
|
||||
const values = editRackForm.getFieldsValue()
|
||||
try {
|
||||
await updateRack.mutateAsync({ id: editRack.id, body: stripEmpty(values) as any })
|
||||
message.success('Стойка обновлена')
|
||||
setEditRack(null)
|
||||
} catch {
|
||||
message.error('Ошибка при обновлении стойки')
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDevices = useMemo(() => {
|
||||
if (!searchText) return devices ?? []
|
||||
const q = searchText.toLowerCase()
|
||||
|
|
@ -276,6 +346,46 @@ export function InventoryObjectDetailPage() {
|
|||
)
|
||||
}, [devices, searchText])
|
||||
|
||||
const groupedTableData = useMemo((): TableRow[] => {
|
||||
const servers = filteredDevices.filter((d) => SERVER_CATEGORIES.has(d.category))
|
||||
const rest = filteredDevices.filter((d) => !SERVER_CATEGORIES.has(d.category))
|
||||
|
||||
const rackMap = new Map<string, DeviceItem[]>()
|
||||
const noRack: DeviceItem[] = []
|
||||
for (const d of rest) {
|
||||
if (d.rack_name) {
|
||||
if (!rackMap.has(d.rack_name)) rackMap.set(d.rack_name, [])
|
||||
rackMap.get(d.rack_name)!.push(d)
|
||||
} else {
|
||||
noRack.push(d)
|
||||
}
|
||||
}
|
||||
|
||||
const rows: TableRow[] = []
|
||||
const addGroup = (label: string, key: string, items: DeviceItem[]) => {
|
||||
rows.push({
|
||||
_type: 'group',
|
||||
_key: key,
|
||||
label,
|
||||
count: items.length,
|
||||
onlineCount: items.filter((d) => d.status === 'online').length,
|
||||
})
|
||||
items.forEach((d) => rows.push({ ...d, _type: 'device' }))
|
||||
}
|
||||
|
||||
if (servers.length > 0) addGroup('Серверная инфраструктура', '__servers__', servers)
|
||||
|
||||
const sortedRacks = [...rackMap.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [rackName, items] of sortedRacks) {
|
||||
const label = rackLabel(rackName, items[0]?.rack_type)
|
||||
addGroup(label, `rack:${rackName}`, items)
|
||||
}
|
||||
|
||||
if (noRack.length > 0) addGroup('Без привязки', '__norack__', noRack)
|
||||
|
||||
return rows
|
||||
}, [filteredDevices])
|
||||
|
||||
if (objLoading) return <Spin />
|
||||
|
||||
const deviceColumns = [
|
||||
|
|
@ -283,88 +393,111 @@ export function InventoryObjectDetailPage() {
|
|||
title: 'IP',
|
||||
dataIndex: 'ip',
|
||||
key: 'ip',
|
||||
render: (ip: string) => <code>{ip}</code>,
|
||||
onCell: (row: TableRow) =>
|
||||
row._type === 'group'
|
||||
? { colSpan: DEVICE_COL_SPAN, style: { background: '#f5f5f5', padding: '6px 16px', borderBottom: '1px solid #e8e8e8' } }
|
||||
: {},
|
||||
render: (_: unknown, row: TableRow) => {
|
||||
if (row._type === 'group') {
|
||||
return (
|
||||
<Space size={8}>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>{row.label}</Typography.Text>
|
||||
<Tag style={{ marginLeft: 2 }}>{row.count} устройств</Tag>
|
||||
{row.onlineCount > 0 && <Tag color="green">Online: {row.onlineCount}</Tag>}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return <code>{(row as DeviceRow).ip}</code>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Hostname',
|
||||
dataIndex: 'hostname',
|
||||
key: 'hostname',
|
||||
render: (h: string | null) => h ?? '—',
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) =>
|
||||
row._type === 'group' ? null : ((row as DeviceRow).hostname ?? '—'),
|
||||
},
|
||||
{
|
||||
title: 'Стойка / локация',
|
||||
key: 'location',
|
||||
render: (_: unknown, row: DeviceItem) => {
|
||||
if (row.rack_name) return <Tag color="blue">{row.rack_name}</Tag>
|
||||
const loc = (row as any).location
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) => {
|
||||
if (row._type === 'group') return null
|
||||
const d = row as DeviceRow
|
||||
if (d.rack_name) return <Tag color="blue">{rackLabel(d.rack_name, d.rack_type)}</Tag>
|
||||
const loc = (d 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,
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) =>
|
||||
row._type === 'group' ? null : <CategoryTag category={(row as DeviceRow).category} />,
|
||||
},
|
||||
{
|
||||
title: 'Статус',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (s: string) => <DeviceStatusBadge status={s} />,
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) =>
|
||||
row._type === 'group' ? null : <DeviceStatusBadge status={(row as DeviceRow).status} />,
|
||||
},
|
||||
{
|
||||
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>
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) => {
|
||||
if (row._type === 'group') return null
|
||||
const d = row as DeviceRow
|
||||
return d.last_seen ? (
|
||||
<Tooltip title={dayjs(d.last_seen).format('DD.MM.YYYY HH:mm:ss')}>
|
||||
<span>{d.last_ping_ms}ms</span>
|
||||
</Tooltip>
|
||||
) : '—',
|
||||
) : '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Источник',
|
||||
dataIndex: 'source',
|
||||
key: 'source',
|
||||
render: (s: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{s}</Typography.Text>,
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) =>
|
||||
row._type === 'group' ? null : (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{(row as DeviceRow).source}
|
||||
</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>
|
||||
),
|
||||
onCell: (row: TableRow) => row._type === 'group' ? { colSpan: 0 } : {},
|
||||
render: (_: unknown, row: TableRow) => {
|
||||
if (row._type === 'group') return null
|
||||
const d = row as DeviceRow
|
||||
return (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(d)} />
|
||||
<Popconfirm
|
||||
title="Удалить устройство?"
|
||||
okText="Удалить"
|
||||
cancelText="Отмена"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDeleteDevice(d)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -391,24 +524,55 @@ export function InventoryObjectDetailPage() {
|
|||
]
|
||||
|
||||
const rackColumns = [
|
||||
{ title: 'Название', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: 'Название',
|
||||
key: 'name',
|
||||
render: (_: unknown, row: RackItem) => {
|
||||
if (editRack?.id === row.id) {
|
||||
return (
|
||||
<Space>
|
||||
<span>{row.name}</span>
|
||||
<Form form={editRackForm} layout="inline" size="small">
|
||||
<Form.Item name="rack_type" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="Тип (entry, exit...)" style={{ width: 130 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="Расположение" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Button size="small" type="primary" onClick={handleSaveRack} loading={updateRack.isPending}>Сохранить</Button>
|
||||
<Button size="small" onClick={() => setEditRack(null)}>Отмена</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{row.name}
|
||||
{row.rack_type ? <Tag style={{ marginLeft: 6 }}>{row.rack_type}</Tag> : null}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ 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,
|
||||
width: 90,
|
||||
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>
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEditRack(row)} />
|
||||
<Popconfirm
|
||||
title="Удалить стойку?"
|
||||
description="Устройства останутся, привязка к стойке будет снята."
|
||||
okText="Удалить"
|
||||
cancelText="Отмена"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
|
@ -473,21 +637,48 @@ export function InventoryObjectDetailPage() {
|
|||
<Button icon={<ThunderboltOutlined />} onClick={() => setRackModal(true)}>
|
||||
Стойки и зоны
|
||||
</Button>
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleDeleteObject} loading={forceDeleteObject.isPending}>
|
||||
Удалить объект
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Input.Search
|
||||
placeholder="Поиск по IP или hostname..."
|
||||
allowClear
|
||||
style={{ marginBottom: 12, maxWidth: 360 }}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
||||
<Col>
|
||||
<Input.Search
|
||||
placeholder="Поиск по IP или hostname..."
|
||||
allowClear
|
||||
style={{ width: 360 }}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Popconfirm
|
||||
title="Удалить все устройства?"
|
||||
description={`Будет удалено ${devices?.length ?? 0} устройств. Отменить нельзя.`}
|
||||
okText="Удалить все"
|
||||
cancelText="Отмена"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={handleDeleteAllDevices}
|
||||
disabled={!devices?.length}
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deleteAllDevices.isPending}
|
||||
disabled={!devices?.length}
|
||||
>
|
||||
Удалить все устройства
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
dataSource={filteredDevices}
|
||||
dataSource={groupedTableData}
|
||||
columns={deviceColumns}
|
||||
rowKey="id"
|
||||
rowKey={(row) => row._type === 'group' ? row._key : String((row as DeviceRow).id)}
|
||||
loading={devLoading}
|
||||
size="middle"
|
||||
pagination={{ pageSize: 50 }}
|
||||
|
|
@ -497,7 +688,7 @@ export function InventoryObjectDetailPage() {
|
|||
<Modal
|
||||
title="Стойки и зоны"
|
||||
open={rackModal}
|
||||
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }}
|
||||
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields(); setEditRack(null) }}
|
||||
footer={null}
|
||||
width={820}
|
||||
>
|
||||
|
|
@ -522,10 +713,13 @@ export function InventoryObjectDetailPage() {
|
|||
<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 }} />
|
||||
<Input placeholder="Название стойки" style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rack_type">
|
||||
<Input placeholder="Тип (entry, exit...)" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="zone_id">
|
||||
<Select placeholder="Зона (опц.)" allowClear style={{ width: 150 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
|
||||
<Select placeholder="Зона (опц.)" allowClear style={{ width: 130 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="location">
|
||||
<Input placeholder="Расположение (опц.)" style={{ width: 150 }} />
|
||||
|
|
@ -687,6 +881,14 @@ export function InventoryObjectDetailPage() {
|
|||
</Form>
|
||||
</Modal>
|
||||
|
||||
<DiscoveryDrawer
|
||||
open={discoveryOpen}
|
||||
onClose={() => setDiscoveryOpen(false)}
|
||||
objectName={obj?.name ?? ''}
|
||||
taskId={discoveryTaskId}
|
||||
onComplete={handleDiscoveryComplete}
|
||||
/>
|
||||
|
||||
{/* Google Sheets Sync Modal */}
|
||||
<Modal
|
||||
title="Синхронизация из Google Sheets"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
UploadOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
AutoComplete,
|
||||
Button,
|
||||
Checkbox,
|
||||
|
|
@ -13,8 +21,10 @@ import {
|
|||
Popconfirm,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
|
@ -25,7 +35,10 @@ import {
|
|||
useObjectsMeta,
|
||||
useUpdateObject,
|
||||
useObjects,
|
||||
usePreviewObjectsXLSX,
|
||||
useImportObjectsXLSX,
|
||||
type ObjectItem,
|
||||
type ObjectImportPreview,
|
||||
} from '../api/objects'
|
||||
import { stripEmpty } from '../utils/form'
|
||||
|
||||
|
|
@ -48,6 +61,14 @@ export function ObjectsPage({ mode }: Props) {
|
|||
const [searchText, setSearchText] = useState('')
|
||||
const [form] = Form.useForm()
|
||||
|
||||
// Import modal state
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importPreview, setImportPreview] = useState<ObjectImportPreview | null>(null)
|
||||
const [importStep, setImportStep] = useState<'upload' | 'preview'>('upload')
|
||||
const previewMutation = usePreviewObjectsXLSX()
|
||||
const importMutation = useImportObjectsXLSX()
|
||||
|
||||
const objectUrl = (id: number) =>
|
||||
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
|
||||
|
||||
|
|
@ -133,6 +154,39 @@ export function ObjectsPage({ mode }: Props) {
|
|||
|
||||
const isSubmitting = createObject.isPending || updateObject.isPending
|
||||
|
||||
const openImport = () => {
|
||||
setImportFile(null)
|
||||
setImportPreview(null)
|
||||
setImportStep('upload')
|
||||
setImportOpen(true)
|
||||
}
|
||||
|
||||
const handleImportFileChange = async (file: File) => {
|
||||
setImportFile(file)
|
||||
setImportPreview(null)
|
||||
try {
|
||||
const preview = await previewMutation.mutateAsync(file)
|
||||
setImportPreview(preview)
|
||||
setImportStep('preview')
|
||||
} catch {
|
||||
message.error('Не удалось прочитать файл')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportConfirm = async () => {
|
||||
if (!importFile) return
|
||||
try {
|
||||
const result = await importMutation.mutateAsync(importFile)
|
||||
setImportOpen(false)
|
||||
message.success(
|
||||
`Импорт завершён: создано ${result.created}, пропущено ${result.skipped}` +
|
||||
(result.errors.length ? `, ошибок ${result.errors.length}` : '')
|
||||
)
|
||||
} catch {
|
||||
message.error('Ошибка при импорте')
|
||||
}
|
||||
}
|
||||
|
||||
const renderObjectRow = (obj: ObjectItem) => (
|
||||
<div
|
||||
key={obj.id}
|
||||
|
|
@ -146,7 +200,7 @@ export function ObjectsPage({ mode }: Props) {
|
|||
>
|
||||
<Button
|
||||
type="link"
|
||||
style={{ padding: 0, fontWeight: 500, flex: 1, textAlign: 'left' }}
|
||||
style={{ padding: 0, fontWeight: 500, flex: 1, textAlign: 'left', justifyContent: 'flex-start' }}
|
||||
onClick={() => navigate(objectUrl(obj.id))}
|
||||
>
|
||||
<RightOutlined style={{ fontSize: 11, marginRight: 6, color: '#999' }} />
|
||||
|
|
@ -212,9 +266,14 @@ export function ObjectsPage({ mode }: Props) {
|
|||
onChange={(e) => setSearchText(e.target.value)}
|
||||
/>
|
||||
{mode === 'inventory' && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
<Space>
|
||||
<Button icon={<UploadOutlined />} onClick={openImport}>
|
||||
Импортировать xlsx
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
Добавить
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
|
@ -230,6 +289,163 @@ export function ObjectsPage({ mode }: Props) {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Import xlsx modal */}
|
||||
<Modal
|
||||
title="Импорт объектов из xlsx"
|
||||
open={importOpen}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
width={900}
|
||||
footer={
|
||||
importStep === 'preview' && importPreview
|
||||
? [
|
||||
<Button key="back" onClick={() => setImportStep('upload')}>
|
||||
Назад
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
loading={importMutation.isPending}
|
||||
disabled={importPreview.would_create.length === 0}
|
||||
onClick={handleImportConfirm}
|
||||
>
|
||||
Создать {importPreview.would_create.length} объектов
|
||||
</Button>,
|
||||
]
|
||||
: null
|
||||
}
|
||||
>
|
||||
{importStep === 'upload' && (
|
||||
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
||||
<Upload.Dragger
|
||||
accept=".xlsx"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleImportFileChange(file)
|
||||
return false
|
||||
}}
|
||||
style={{ padding: '16px 32px' }}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined style={{ fontSize: 48, color: '#1677ff' }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">Перетащите .xlsx файл или нажмите для выбора</p>
|
||||
<p className="ant-upload-hint" style={{ color: '#999' }}>
|
||||
Поддерживается шаблон objects_import_template.xlsx
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
{previewMutation.isPending && (
|
||||
<Typography.Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
|
||||
Анализируем файл...
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importStep === 'preview' && importPreview && (
|
||||
<div>
|
||||
{/* Summary */}
|
||||
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Tag color="green" style={{ fontSize: 13, padding: '2px 10px' }}>
|
||||
Будет создано: {importPreview.would_create.length}
|
||||
</Tag>
|
||||
{importPreview.would_skip.length > 0 && (
|
||||
<Tag color="orange" style={{ fontSize: 13, padding: '2px 10px' }}>
|
||||
Пропущено (дубли): {importPreview.would_skip.length}
|
||||
</Tag>
|
||||
)}
|
||||
{importPreview.errors.length > 0 && (
|
||||
<Tag color="red" style={{ fontSize: 13, padding: '2px 10px' }}>
|
||||
Ошибок: {importPreview.errors.length}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* Would create table */}
|
||||
{importPreview.would_create.length > 0 && (
|
||||
<>
|
||||
<Typography.Text strong>Будет создано</Typography.Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8, marginBottom: 16 }}
|
||||
dataSource={importPreview.would_create}
|
||||
rowKey="row"
|
||||
pagination={{ pageSize: 10, hideOnSinglePage: true }}
|
||||
columns={[
|
||||
{ title: '#', dataIndex: 'row', width: 50 },
|
||||
{ title: 'Название', dataIndex: 'name' },
|
||||
{ title: 'Город', dataIndex: 'city', width: 130 },
|
||||
{
|
||||
title: 'IP сервера',
|
||||
dataIndex: 'server_ip',
|
||||
width: 140,
|
||||
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: 'Клиент', dataIndex: 'client', width: 130, render: (v) => v ?? '—' },
|
||||
{
|
||||
title: 'SSH',
|
||||
dataIndex: 'ssh_user',
|
||||
width: 80,
|
||||
render: (v: string) => <Typography.Text type="secondary">{v}</Typography.Text>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Skipped */}
|
||||
{importPreview.would_skip.length > 0 && (
|
||||
<>
|
||||
<Typography.Text strong>
|
||||
<WarningOutlined style={{ color: '#fa8c16', marginRight: 6 }} />
|
||||
Пропущены (дубликаты по IP)
|
||||
</Typography.Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8, marginBottom: 16 }}
|
||||
dataSource={importPreview.would_skip}
|
||||
rowKey="row"
|
||||
pagination={{ pageSize: 5, hideOnSinglePage: true }}
|
||||
columns={[
|
||||
{ title: '#', dataIndex: 'row', width: 50 },
|
||||
{ title: 'Название', dataIndex: 'name' },
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'server_ip',
|
||||
width: 140,
|
||||
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: 'Причина', dataIndex: 'reason' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{importPreview.errors.length > 0 && (
|
||||
<>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 8 }}
|
||||
message={`${importPreview.errors.length} строк с ошибками будут пропущены`}
|
||||
/>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
dataSource={importPreview.errors}
|
||||
rowKey="row"
|
||||
pagination={{ pageSize: 5, hideOnSinglePage: true }}
|
||||
columns={[
|
||||
{ title: '#', dataIndex: 'row', width: 50 },
|
||||
{ title: 'Ошибка', dataIndex: 'reason' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create / Edit modal — only relevant in inventory mode */}
|
||||
<Modal
|
||||
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
|
||||
|
|
|
|||
BIN
objects_import_template (2).xlsx
Normal file
BIN
objects_import_template (2).xlsx
Normal file
Binary file not shown.
BIN
objects_import_template.xlsx
Normal file
BIN
objects_import_template.xlsx
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue