This commit is contained in:
dv 2026-04-08 23:00:09 +03:00
commit d0c2054c43
20 changed files with 2231 additions and 136 deletions

View file

@ -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:*)"
]
}
}

View 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")

View file

@ -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):

View file

@ -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):

View file

@ -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)

View file

@ -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,

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View 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}")

View file

@ -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({

View file

@ -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] }),
})

View 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>
)
}

View file

@ -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"

View file

@ -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}`}

Binary file not shown.

Binary file not shown.