эээ
This commit is contained in:
parent
2983e4e5e7
commit
9bd22bf3d9
8 changed files with 651 additions and 7 deletions
|
|
@ -7,7 +7,13 @@
|
||||||
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")",
|
"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 \"[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(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:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||||
from sqlalchemy import select, distinct
|
from sqlalchemy import select, distinct
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
@ -11,7 +13,19 @@ from app.models.object import Object
|
||||||
from app.models.tag import Tag
|
from app.models.tag import Tag
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.admin import SheetsSyncRequest
|
from app.schemas.admin import SheetsSyncRequest
|
||||||
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult, DiscoveryStarted
|
from app.schemas.object import (
|
||||||
|
ObjectCreate,
|
||||||
|
ObjectImportError,
|
||||||
|
ObjectImportPreview,
|
||||||
|
ObjectImportResult,
|
||||||
|
ObjectImportRow,
|
||||||
|
ObjectImportSkipped,
|
||||||
|
ObjectMetaResponse,
|
||||||
|
ObjectRead,
|
||||||
|
ObjectUpdate,
|
||||||
|
SyncResult,
|
||||||
|
DiscoveryStarted,
|
||||||
|
)
|
||||||
from app.services import crypto
|
from app.services import crypto
|
||||||
from app.services.database import AsyncSessionLocal
|
from app.services.database import AsyncSessionLocal
|
||||||
from app.services.device_discovery import discover_via_ssh
|
from app.services.device_discovery import discover_via_ssh
|
||||||
|
|
@ -215,6 +229,182 @@ async def discover_devices(
|
||||||
return DiscoveryStarted(task_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:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
|
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
|
||||||
async def sync_devices_from_sheets(
|
async def sync_devices_from_sheets(
|
||||||
obj_id: int,
|
obj_id: int,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,44 @@ class ObjectMetaResponse(BaseModel):
|
||||||
clients: list[str]
|
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):
|
class SyncResult(BaseModel):
|
||||||
created: int
|
created: int
|
||||||
updated: int
|
updated: int
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ dependencies = [
|
||||||
# Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var)
|
# Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var)
|
||||||
"gspread>=6.0",
|
"gspread>=6.0",
|
||||||
"google-auth>=2.0",
|
"google-auth>=2.0",
|
||||||
|
"openpyxl>=3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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}")
|
||||||
|
|
@ -261,6 +261,69 @@ export const useObjectsMeta = () =>
|
||||||
staleTime: 30_000,
|
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) => {
|
export const useSyncSheets = (objId: number) => {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
|
|
||||||
import {
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
WarningOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
AutoComplete,
|
AutoComplete,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
|
@ -13,8 +21,10 @@ import {
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
|
Upload,
|
||||||
message,
|
message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
@ -25,7 +35,10 @@ import {
|
||||||
useObjectsMeta,
|
useObjectsMeta,
|
||||||
useUpdateObject,
|
useUpdateObject,
|
||||||
useObjects,
|
useObjects,
|
||||||
|
usePreviewObjectsXLSX,
|
||||||
|
useImportObjectsXLSX,
|
||||||
type ObjectItem,
|
type ObjectItem,
|
||||||
|
type ObjectImportPreview,
|
||||||
} from '../api/objects'
|
} from '../api/objects'
|
||||||
import { stripEmpty } from '../utils/form'
|
import { stripEmpty } from '../utils/form'
|
||||||
|
|
||||||
|
|
@ -48,6 +61,15 @@ export function ObjectsPage({ mode }: Props) {
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [form] = Form.useForm()
|
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 fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const objectUrl = (id: number) =>
|
const objectUrl = (id: number) =>
|
||||||
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
|
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
|
||||||
|
|
||||||
|
|
@ -133,6 +155,39 @@ export function ObjectsPage({ mode }: Props) {
|
||||||
|
|
||||||
const isSubmitting = createObject.isPending || updateObject.isPending
|
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) => (
|
const renderObjectRow = (obj: ObjectItem) => (
|
||||||
<div
|
<div
|
||||||
key={obj.id}
|
key={obj.id}
|
||||||
|
|
@ -212,9 +267,14 @@ export function ObjectsPage({ mode }: Props) {
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{mode === 'inventory' && (
|
{mode === 'inventory' && (
|
||||||
|
<Space>
|
||||||
|
<Button icon={<UploadOutlined />} onClick={openImport}>
|
||||||
|
Импортировать xlsx
|
||||||
|
</Button>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</Button>
|
||||||
|
</Space>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -230,6 +290,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 */}
|
{/* Create / Edit modal — only relevant in inventory mode */}
|
||||||
<Modal
|
<Modal
|
||||||
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
|
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.
Loading…
Add table
Reference in a new issue