vibe cringe

This commit is contained in:
dv 2026-04-07 23:46:54 +03:00
parent 2ba10307b3
commit c724a1c175
17 changed files with 2036 additions and 115 deletions

View file

@ -2,7 +2,11 @@
"permissions": {
"allow": [
"Bash(xargs grep:*)",
"Bash(grep -E \"\\\\.py$\")"
"Bash(grep -E \"\\\\.py$\")",
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\")",
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")",
"Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")",
"Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")"
]
}
}

View file

@ -0,0 +1,34 @@
"""0008 inventory fields
Adds location, connection_params, vendor columns to devices.
Revision ID: 0008
Revises: 0007
Create Date: 2026-04-07
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("devices", sa.Column("vendor", sa.String(255), nullable=True))
op.add_column("devices", sa.Column("location", sa.String(20), nullable=True))
op.add_column(
"devices",
sa.Column("connection_params", JSONB, nullable=False, server_default="{}"),
)
op.create_index("ix_devices_location", "devices", ["location"])
def downgrade() -> None:
op.drop_index("ix_devices_location", table_name="devices")
op.drop_column("devices", "connection_params")
op.drop_column("devices", "location")
op.drop_column("devices", "vendor")

View file

@ -0,0 +1,28 @@
"""0009 object city and client fields
Revision ID: 0009
Revises: 0008
Create Date: 2026-04-07
"""
import sqlalchemy as sa
from alembic import op
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("objects", sa.Column("city", sa.String(100), nullable=True))
op.add_column("objects", sa.Column("client", sa.String(255), nullable=True))
op.create_index("ix_objects_city", "objects", ["city"])
op.create_index("ix_objects_client", "objects", ["client"])
def downgrade() -> None:
op.drop_index("ix_objects_client", table_name="objects")
op.drop_index("ix_objects_city", table_name="objects")
op.drop_column("objects", "client")
op.drop_column("objects", "city")

View file

@ -6,7 +6,7 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware
from app.plugins.loader import load_plugins
from app.routers import admin, alerts, auth, devices, events, objects, racks, snapshots, tags, tasks, zones
from app.routers import admin, alerts, auth, devices, events, global_devices, objects, racks, snapshots, tags, tasks, zones
from app.workers.monitor import start_monitor, stop_monitor
from app.workers.startup import recover_stale_tasks
@ -41,6 +41,7 @@ def create_app() -> FastAPI:
app.include_router(auth.router)
app.include_router(objects.router)
app.include_router(global_devices.router)
app.include_router(devices.router)
app.include_router(racks.router)
app.include_router(zones.router)

View file

@ -7,8 +7,24 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
DEVICE_CATEGORIES = ("raspberry", "mikrotik", "camera", "embedded", "server", "other")
DEVICE_ROLES = ("entry", "exit", "server", "nvr", "switch", "other")
DEVICE_CATEGORIES = (
"main_server", # caps-cs — основной сервер объекта
"vm", # виртуалки: БД, Asterisk, RTSP, recognition
"router", # MikroTik, IRZ и прочие роутеры
"embedded", # пром-ПК стоек (Raspberry Pi)
"camera", # камеры (role: plate / face / overview)
"io_board", # модули ввода/вывода (Овен МК210-302)
"bank_terminal", # банковские терминалы (SberPilot, PAX IM20)
"cash_register", # ККТ (PayOnline-01-ФА)
"other", # всё остальное: RFID, табло, реле, IP-телефоны
)
# Используется только для camera (остальным выставляется "other" или NULL)
DEVICE_ROLES = ("plate", "face", "overview", "other")
# Физическая локация устройств не привязанных к стойке
DEVICE_LOCATIONS = ("server_room", "street")
DEVICE_STATUSES = ("online", "offline", "unknown")
DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync")
@ -31,6 +47,7 @@ class Device(Base, TimestampMixin):
# Classification
category: Mapped[str] = mapped_column(String(20), nullable=False, default="other")
role: Mapped[str] = mapped_column(String(20), nullable=False, default="other")
vendor: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
model: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
firmware_version: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
@ -64,6 +81,18 @@ class Device(Base, TimestampMixin):
index=True,
)
# Для устройств не в стойке: "server_room" | "street" | None
# rack_id и location взаимоисключают друг друга
location: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)
# Параметры подключения для операционной вебки (Вариант В)
# camera: { auth_type, username, password, frame_url, stream_url }
# bank_terminal: { port, timeout_on_sale }
# cash_register: { port, tax, taxation }
# embedded: { config_path }
# io_board: { port, protocol }
connection_params: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View file

@ -40,6 +40,9 @@ class Object(Base, TimestampMixin):
ssh_pass_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ssh_port: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
city: Mapped[Optional[str]] = mapped_column(String(100), nullable=True, index=True)
client: Mapped[Optional[str]] = mapped_column(String(255), nullable=True, index=True)
notes: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)

View file

@ -0,0 +1,58 @@
"""Global devices endpoint — cross-object device listing with filters."""
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.dependencies import get_current_user, get_db
from app.models.device import Device
from app.models.object import Object
from app.models.user import User
from app.schemas.device import DeviceRead
router = APIRouter(prefix="/api/v1/devices", tags=["devices-global"])
@router.get("", response_model=list[DeviceRead])
async def list_all_devices(
category: Optional[list[str]] = None,
city: Optional[str] = None,
client: Optional[str] = None,
object_id: Optional[int] = None,
status: Optional[str] = None,
is_active: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""List devices across all objects with optional filters.
Typical use cases:
- All main_server devices: ?category=main_server
- All bank terminals for a client: ?category=bank_terminal&client=Авангард
- All devices in a city: ?city=Санкт-Петербург
"""
query = (
select(Device)
.join(Object, Device.object_id == Object.id)
.options(selectinload(Device.rack))
.order_by(Object.city, Object.name, Device.category, Device.ip)
)
if category:
query = query.where(Device.category.in_(category))
if city is not None:
query = query.where(Object.city == city)
if client is not None:
query = query.where(Object.client == client)
if object_id is not None:
query = query.where(Device.object_id == object_id)
if status is not None:
query = query.where(Device.status == status)
if is_active is not None:
query = query.where(Device.is_active == is_active)
result = await db.execute(query)
return result.scalars().all()

View file

@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy import select, distinct
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@ -9,8 +9,9 @@ from app.models.object import Object
from app.models.tag import Tag
from app.models.user import User
from app.schemas.admin import SheetsSyncRequest
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate, SyncResult
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult
from app.services import crypto
from app.services.device_discovery import discover_via_ssh
from app.services.object_db import sync_from_object_db
from app.services.sheets import sync_from_sheets
@ -34,10 +35,30 @@ async def _load_object(db: AsyncSession, obj_id: int) -> Object:
return obj
@router.get("/meta", response_model=ObjectMetaResponse)
async def get_objects_meta(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Return distinct city and client values for autocomplete."""
cities_result = await db.execute(
select(distinct(Object.city)).where(Object.city.isnot(None)).order_by(Object.city)
)
clients_result = await db.execute(
select(distinct(Object.client)).where(Object.client.isnot(None)).order_by(Object.client)
)
return ObjectMetaResponse(
cities=[r for (r,) in cities_result.all()],
clients=[r for (r,) in clients_result.all()],
)
@router.get("", response_model=list[ObjectRead])
async def list_objects(
is_active: bool | None = None,
tag_id: int | None = None,
city: str | None = None,
client: str | None = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
@ -46,7 +67,11 @@ async def list_objects(
query = query.where(Object.is_active == is_active)
if tag_id is not None:
query = query.where(Object.tags.any(Tag.id == tag_id))
query = query.order_by(Object.name)
if city is not None:
query = query.where(Object.city == city)
if client is not None:
query = query.where(Object.client == client)
query = query.order_by(Object.city, Object.client, Object.name)
result = await db.execute(query)
return result.scalars().all()
@ -140,6 +165,23 @@ async def sync_devices(
)
@router.post("/{obj_id}/discover", response_model=SyncResult)
async def discover_devices(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""SSH into each rack PC, parse caps config, discover network devices."""
obj = await _load_object(db, obj_id)
result = await discover_via_ssh(db, obj)
return SyncResult(
created=result.created,
updated=result.updated,
skipped=result.skipped,
errors=result.errors,
)
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
async def sync_devices_from_sheets(
obj_id: int,

View file

@ -10,6 +10,8 @@ class ObjectCreate(BaseModel):
name: str
description: Optional[str] = None
server_ip: Optional[str] = None
city: Optional[str] = None
client: Optional[str] = None
# Object DB sync settings
db_host: Optional[str] = None
db_port: int = 5432
@ -33,6 +35,8 @@ class ObjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
server_ip: Optional[str] = None
city: Optional[str] = None
client: Optional[str] = None
db_host: Optional[str] = None
db_port: Optional[int] = None
db_name: Optional[str] = None
@ -55,6 +59,8 @@ class ObjectRead(BaseModel):
name: str
description: Optional[str] = None
server_ip: Optional[str] = None
city: Optional[str] = None
client: Optional[str] = None
db_host: Optional[str] = None
db_port: int
db_name: Optional[str] = None
@ -74,6 +80,11 @@ class ObjectRead(BaseModel):
model_config = {"from_attributes": True}
class ObjectMetaResponse(BaseModel):
cities: list[str]
clients: list[str]
class SyncResult(BaseModel):
created: int
updated: int

View file

@ -0,0 +1,299 @@
"""
SSH-based device discovery: connects to each rack PC, reads its caps config,
extracts plugin URIs and classifies discovered network devices.
Discovery chain:
Object DB racks table (host IPs) SSH per rack parse config upsert devices
Limitations (devices not discoverable automatically):
- Serial/USB devices (no network URI)
- RODOS-8 relays, Moxa switches (not represented as plugins)
- Physical MikroTik router (not in rack configs)
"""
import re
from configparser import RawConfigParser
from dataclasses import dataclass, field
from io import StringIO
from urllib.parse import urlparse
import asyncssh
import psycopg
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.device import Device
from app.models.object import Object
from app.services.crypto import decrypt
# ── URI extraction ────────────────────────────────────────────────────────────
_SERIAL_SCHEMES = {"serial", "com"}
_SERVICE_HOSTS = {"localhost", "127.0.0.1"}
def _extract_ip(uri: str) -> str | None:
"""Return IP from a URI string, or None if not a network device."""
if not uri:
return None
try:
parsed = urlparse(uri)
scheme = (parsed.scheme or "").lower()
if scheme in _SERIAL_SCHEMES:
return None
hostname = parsed.hostname
if not hostname or hostname in _SERVICE_HOSTS:
return None
# Validate it looks like an IP (simple check)
if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", hostname):
return hostname
except Exception:
pass
return None
# ── Plugin class → device category ───────────────────────────────────────────
def _cls_to_category(cls: str) -> str:
cls_lower = cls.lower()
if "camera" in cls_lower or "facedetect" in cls_lower:
return "camera"
if "controller" in cls_lower or "ovenmk" in cls_lower or "rodos" in cls_lower:
return "io_board"
if "payment" in cls_lower or "sberpilot" in cls_lower or "payx" in cls_lower:
return "bank_terminal"
if "payonline" in cls_lower or "kkt" in cls_lower or "fiscal" in cls_lower:
return "cash_register"
return "other"
def _cls_to_role(cls: str, plugin_name: str) -> str:
cls_lower = cls.lower()
name_lower = plugin_name.lower()
if "camera" in cls_lower or "camera" in name_lower:
if "grz" in name_lower or "plate" in name_lower or "lpr" in name_lower or "frame" in name_lower:
return "plate"
if "face" in name_lower or "lico" in name_lower:
return "face"
return "overview"
return "other"
# Plugins whose URIs point at the CAPS server itself, not at separate devices
_SKIP_PLUGIN_PREFIXES = ("mnemo", "processor", "recognition", "photo", "voice")
# ── Config parsing ────────────────────────────────────────────────────────────
@dataclass
class DiscoveredDevice:
ip: str
category: str
role: str
plugin_name: str
cls: str
def _parse_config(config_text: str, rack_host: str) -> list[DiscoveredDevice]:
"""Extract discovered network devices from a caps config file."""
parser = RawConfigParser()
parser.read_string(config_text)
discovered: list[DiscoveredDevice] = []
seen_ips: set[str] = set()
for section in parser.sections():
if not section.startswith("plugin:"):
continue
plugin_name = section[len("plugin:"):]
if any(plugin_name.startswith(prefix) for prefix in _SKIP_PLUGIN_PREFIXES):
continue
cls = parser.get(section, "cls", fallback="")
if not cls:
continue
# Collect all URI-like values in this section
uri_fields = ("uri", "frame_uri", "host")
for field_name in uri_fields:
raw = parser.get(section, field_name, fallback=None)
if not raw:
continue
ip = _extract_ip(raw)
if not ip or ip == rack_host or ip in seen_ips:
continue
seen_ips.add(ip)
discovered.append(DiscoveredDevice(
ip=ip,
category=_cls_to_category(cls),
role=_cls_to_role(cls, plugin_name),
plugin_name=plugin_name,
cls=cls,
))
return discovered
# ── SSH operations ────────────────────────────────────────────────────────────
async def _read_remote_config(
host: str,
port: int,
username: str,
password: str,
config_path: str,
) -> str:
async with asyncssh.connect(
host,
port=port,
username=username,
password=password,
known_hosts=None,
connect_timeout=10,
) as conn:
result = await conn.run(f"cat {config_path}", check=True)
return result.stdout
async def _get_rack_hosts(obj: Object, db_password: str) -> list[tuple[str, str]]:
"""Return list of (external_id, host_ip) from the object's racks table."""
if obj.db_via_tunnel and obj.server_ip and obj.ssh_user and obj.ssh_pass_enc:
ssh_password = decrypt(obj.ssh_pass_enc)
async with asyncssh.connect(
obj.server_ip,
port=obj.ssh_port or 22,
username=obj.ssh_user,
password=ssh_password,
known_hosts=None,
) as tunnel:
listener = await tunnel.forward_local_port(
"127.0.0.1", 0, obj.db_host, obj.db_port or 5432
)
local_port = listener.get_port()
dsn = (
f"host=127.0.0.1 port={local_port} dbname={obj.db_name} "
f"user={obj.db_user} password={db_password} connect_timeout=10"
)
return await _fetch_rack_hosts(dsn, obj.db_table)
else:
dsn = (
f"host={obj.db_host} port={obj.db_port or 5432} dbname={obj.db_name} "
f"user={obj.db_user} password={db_password} connect_timeout=10"
)
return await _fetch_rack_hosts(dsn, obj.db_table)
async def _fetch_rack_hosts(dsn: str, table: str) -> list[tuple[str, str]]:
async with await psycopg.AsyncConnection.connect(dsn) as conn:
cursor = await conn.execute(
f"SELECT id, data->>'host' AS host FROM \"{table}\" WHERE data->>'host' IS NOT NULL"
)
rows = await cursor.fetchall()
return [(str(r[0]), str(r[1])) for r in rows if r[1]]
# ── Main entry point ──────────────────────────────────────────────────────────
@dataclass
class DiscoveryResult:
created: int = 0
updated: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
async def discover_via_ssh(
db: AsyncSession,
obj: Object,
config_path: str = "/etc/caps/caps.conf",
) -> DiscoveryResult:
result = DiscoveryResult()
if not all([obj.db_host, obj.db_name, obj.db_user, obj.db_pass_enc, obj.db_table]):
result.errors.append("Object DB connection is not fully configured")
return result
if not obj.ssh_user or not obj.ssh_pass_enc:
result.errors.append("SSH credentials not configured on this object")
return result
try:
db_password = decrypt(obj.db_pass_enc)
ssh_password = decrypt(obj.ssh_pass_enc)
except Exception:
result.errors.append("Failed to decrypt stored credentials")
return result
# Step 1: get rack hosts from object's DB
try:
rack_hosts = await _get_rack_hosts(obj, db_password)
except Exception as exc:
result.errors.append(f"Cannot fetch rack hosts from object DB: {exc}")
return result
if not rack_hosts:
result.errors.append("No racks with host IPs found in object DB")
return result
# Step 2: SSH into each rack, parse config, upsert devices
for external_id, rack_host in rack_hosts:
# Resolve config_path override from the embedded device's connection_params
existing_rack_pc = (await db.execute(
select(Device).where(
Device.object_id == obj.id,
Device.ip == rack_host,
)
)).scalar_one_or_none()
effective_config_path = config_path
if existing_rack_pc and existing_rack_pc.connection_params:
effective_config_path = existing_rack_pc.connection_params.get(
"config_path", config_path
)
try:
config_text = await _read_remote_config(
host=rack_host,
port=obj.ssh_port or 22,
username=obj.ssh_user,
password=ssh_password,
config_path=effective_config_path,
)
except Exception as exc:
result.errors.append(f"SSH to {rack_host} failed: {exc}")
continue
devices_found = _parse_config(config_text, rack_host)
for found in devices_found:
try:
existing = (await db.execute(
select(Device).where(
Device.object_id == obj.id,
Device.ip == found.ip,
)
)).scalar_one_or_none()
if existing is not None:
changed = False
if existing.category != found.category and existing.source == "auto_ssh":
existing.category = found.category
changed = True
if existing.role == "other" and found.role != "other":
existing.role = found.role
changed = True
result.updated += 1 if changed else 0
result.skipped += 0 if changed else 1
else:
db.add(Device(
object_id=obj.id,
ip=found.ip,
category=found.category,
role=found.role,
source="auto_ssh",
device_meta={"plugin": found.plugin_name, "cls": found.cls},
))
result.created += 1
except Exception as exc:
result.errors.append(f"Error upserting {found.ip}: {exc}")
return result

View file

@ -3,11 +3,13 @@ import { AppLayout } from './components/AppLayout'
import { AdminPage } from './pages/Admin'
import { AlertsPage } from './pages/Alerts'
import { LoginPage } from './pages/Login'
import { ObjectDetailPage } from './pages/ObjectDetail'
import { ObjectsPage } from './pages/Objects'
import { SnapshotsPage } from './pages/Snapshots'
import { TaskDetailPage } from './pages/TaskDetail'
import { TasksPage } from './pages/Tasks'
import { InventoryObjectDetailPage } from './pages/InventoryObjectDetail'
import { WorkObjectDetailPage } from './pages/WorkObjectDetail'
import { WorkDevicesPage } from './pages/WorkDevices'
import { useAuthStore } from './store/auth'
function RequireAuth({ children }: { children: React.ReactNode }) {
@ -27,14 +29,27 @@ export default function App() {
</RequireAuth>
}
>
<Route index element={<Navigate to="/objects" replace />} />
<Route path="admin" element={<AdminPage />} />
<Route index element={<Navigate to="/inventory/objects" replace />} />
{/* Инвентарь */}
<Route path="inventory/objects" element={<ObjectsPage mode="inventory" />} />
<Route path="inventory/objects/:id" element={<InventoryObjectDetailPage />} />
<Route path="inventory/objects/:id/snapshots" element={<SnapshotsPage />} />
{/* Работа */}
<Route path="work/objects" element={<ObjectsPage mode="work" />} />
<Route path="work/objects/:id" element={<WorkObjectDetailPage />} />
<Route path="work/devices" element={<WorkDevicesPage />} />
{/* Общие */}
<Route path="alerts" element={<AlertsPage />} />
<Route path="objects" element={<ObjectsPage />} />
<Route path="objects/:id" element={<ObjectDetailPage />} />
<Route path="objects/:id/snapshots" element={<SnapshotsPage />} />
<Route path="tasks" element={<TasksPage />} />
<Route path="tasks/:id" element={<TaskDetailPage />} />
<Route path="admin" element={<AdminPage />} />
{/* Старые URL — редирект для совместимости */}
<Route path="objects" element={<Navigate to="/inventory/objects" replace />} />
<Route path="objects/:id" element={<Navigate to="/inventory/objects" replace />} />
</Route>
</Routes>
)

View file

@ -8,11 +8,18 @@ export interface ObjectItem {
server_ip: string | null
ssh_user: string | null
db_host: string | null
city: string | null
client: string | null
is_active: boolean
tags: { id: number; name: string; color: string }[]
created_at: string
}
export interface ObjectMeta {
cities: string[]
clients: string[]
}
export interface DeviceItem {
id: number
object_id: number
@ -80,6 +87,8 @@ export interface ObjectCreatePayload {
name: string
description?: string
server_ip?: string
city?: string
client?: string
ssh_user?: string
ssh_password?: string
ssh_port?: number
@ -195,11 +204,45 @@ export const useSyncObject = (objId: number) => {
})
}
export const useDiscoverObject = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: () =>
api.post<SyncResult>(`/api/v1/objects/${objId}/discover`).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}
export interface SheetsSyncPayload {
spreadsheet_id: string
sheet_name?: string
}
export interface AllDevicesParams {
category?: string[]
city?: string
client?: string
object_id?: number
status?: string
is_active?: boolean
}
export const useAllDevices = (params: AllDevicesParams = {}) =>
useQuery({
queryKey: ['all-devices', params],
queryFn: () =>
api
.get<DeviceItem[]>('/api/v1/devices', { params })
.then((r) => r.data),
})
export const useObjectsMeta = () =>
useQuery({
queryKey: ['objects-meta'],
queryFn: () => api.get<ObjectMeta>('/api/v1/objects/meta').then((r) => r.data),
staleTime: 30_000,
})
export const useSyncSheets = (objId: number) => {
const qc = useQueryClient()
return useMutation({

View file

@ -1,6 +1,8 @@
import {
ApartmentOutlined,
AppstoreOutlined,
BellOutlined,
ControlOutlined,
DatabaseOutlined,
LogoutOutlined,
SettingOutlined,
ThunderboltOutlined,
@ -21,7 +23,6 @@ export function AppLayout() {
const qc = useQueryClient()
const { username, role, refreshToken, clearTokens } = useAuthStore()
// Open alert count for sidebar badge — refresh every 60s
const { data: openAlerts } = useAlerts({
status: 'open',
limit: 500,
@ -29,23 +30,27 @@ export function AppLayout() {
})
const openCount = openAlerts?.length ?? 0
// Global monitor SSE — invalidates caches when monitor events arrive
useMonitorSSE({
onAlertCreated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
onAlertUpdated: () => qc.invalidateQueries({ queryKey: ['alerts'] }),
onDeviceStatus: (ev) => {
// Invalidate devices for the affected object so status badge updates live
qc.invalidateQueries({ queryKey: ['devices', ev.object_id] })
},
})
const selectedKey = location.pathname.startsWith('/tasks')
const selectedKey = location.pathname.startsWith('/inventory/objects')
? 'inventory-objects'
: location.pathname.startsWith('/work/devices')
? 'work-devices'
: location.pathname.startsWith('/work/objects')
? 'work-objects'
: location.pathname.startsWith('/tasks')
? 'tasks'
: location.pathname.startsWith('/alerts')
? 'alerts'
: location.pathname.startsWith('/admin')
? 'admin'
: 'objects'
: ''
const handleLogout = async () => {
try {
@ -70,11 +75,38 @@ export function AppLayout() {
selectedKeys={[selectedKey]}
items={[
{
key: 'objects',
icon: <ApartmentOutlined />,
key: 'inventory-group',
type: 'group',
label: 'Инвентарь',
children: [
{
key: 'inventory-objects',
icon: <DatabaseOutlined />,
label: 'Объекты',
onClick: () => navigate('/objects'),
onClick: () => navigate('/inventory/objects'),
},
],
},
{
key: 'work-group',
type: 'group',
label: 'Работа',
children: [
{
key: 'work-objects',
icon: <ControlOutlined />,
label: 'По объектам',
onClick: () => navigate('/work/objects'),
},
{
key: 'work-devices',
icon: <AppstoreOutlined />,
label: 'Все устройства',
onClick: () => navigate('/work/devices'),
},
],
},
{ type: 'divider' },
{
key: 'alerts',
icon: <BellOutlined />,

View file

@ -0,0 +1,718 @@
import {
ApiOutlined,
DeleteOutlined,
EditOutlined,
FileTextOutlined,
GoogleOutlined,
PlusOutlined,
SyncOutlined,
ThunderboltOutlined,
UploadOutlined,
} from '@ant-design/icons'
import {
Alert,
Breadcrumb,
Button,
Checkbox,
Col,
Descriptions,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
Upload,
message,
} from 'antd'
import type { UploadFile } from 'antd'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
useCreateDevice,
useDeleteDevice,
useDiscoverObject,
useUpdateDevice,
useDevices,
useImportCSV,
useObject,
usePreviewCSV,
useSyncObject,
useSyncSheets,
type DeviceImportPreview,
type DeviceItem,
} from '../api/objects'
import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks'
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { stripEmpty } from '../utils/form'
import dayjs from 'dayjs'
const DEVICE_CATEGORIES = [
{ value: 'main_server', label: 'Главный сервер' },
{ value: 'vm', label: 'Виртуалка / сервис' },
{ value: 'router', label: 'Роутер / шлюз' },
{ value: 'embedded', label: 'Пром-ПК (embedded)' },
{ value: 'camera', label: 'Камера' },
{ value: 'io_board', label: 'Модуль ввода/вывода' },
{ value: 'bank_terminal', label: 'Банковский терминал' },
{ value: 'cash_register', label: 'ККТ' },
{ value: 'other', label: 'Другое' },
]
const DEVICE_ROLES = [
{ value: 'plate', label: 'ГРЗ (номерная)' },
{ value: 'face', label: 'Лицевая / обзорная' },
{ value: 'overview', label: 'Обзорная (улица)' },
{ value: 'other', label: 'Другое' },
]
const DEVICE_LOCATIONS = [
{ value: 'server_room', label: 'Серверная' },
{ value: 'street', label: 'Улица' },
]
export function InventoryObjectDetailPage() {
const { id } = useParams<{ id: string }>()
const objId = Number(id)
const navigate = useNavigate()
const { data: obj, isLoading: objLoading } = useObject(objId)
const { data: devices, isLoading: devLoading } = useDevices(objId)
const { data: racks } = useRacks(objId)
const { data: zones } = useZones(objId)
const syncMutation = useSyncObject(objId)
const discoverMutation = useDiscoverObject(objId)
const syncSheetsMutation = useSyncSheets(objId)
const createDevice = useCreateDevice(objId)
const updateDevice = useUpdateDevice(objId)
const deleteDevice = useDeleteDevice(objId)
const previewCSV = usePreviewCSV(objId)
const importCSV = useImportCSV(objId)
const createRack = useCreateRack(objId)
const deleteRack = useDeleteRack(objId)
const createZone = useCreateZone(objId)
const deleteZone = useDeleteZone(objId)
const [deviceModal, setDeviceModal] = useState(false)
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false)
const [sheetsModal, setSheetsModal] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
const [searchText, setSearchText] = useState('')
const [deviceForm] = Form.useForm()
const [rackForm] = Form.useForm()
const [zoneForm] = Form.useForm()
const [sheetsForm] = Form.useForm()
const rackOptions = useMemo(
() => (racks ?? []).map((r) => ({ value: r.id, label: r.name })),
[racks]
)
const handleSync = async () => {
try {
const result = await syncMutation.mutateAsync()
message.success(`Синхронизировано: +${result.created} новых, ${result.updated} обновлено`)
} catch {
message.error('Ошибка синхронизации')
}
}
const handleDiscover = async () => {
try {
const result = await discoverMutation.mutateAsync()
const msg = `SSH Discovery: +${result.created} найдено, ${result.updated} обновлено`
if (result.errors.length > 0) {
message.warning(`${msg}. Ошибки: ${result.errors.join('; ')}`)
} else {
message.success(msg)
}
} catch {
message.error('Ошибка SSH discovery')
}
}
const handleSyncSheets = async () => {
try {
const values = await sheetsForm.validateFields()
const result = await syncSheetsMutation.mutateAsync({
spreadsheet_id: values.spreadsheet_id,
sheet_name: values.sheet_name || undefined,
})
message.success(`Google Sheets: +${result.created} создано, ${result.updated} обновлено`)
setSheetsModal(false)
sheetsForm.resetFields()
} catch (err: any) {
const detail = err?.response?.data?.detail
if (detail) message.error(detail)
}
}
const openAddDevice = () => {
deviceForm.resetFields()
setEditDevice(null)
setDeviceModal(true)
}
const openEditDevice = (row: DeviceItem) => {
deviceForm.resetFields()
deviceForm.setFieldsValue({
ip: row.ip,
hostname: row.hostname ?? '',
category: row.category,
role: row.role,
vendor: (row as any).vendor ?? '',
model: row.model ?? '',
mac: row.mac ?? '',
rack_id: row.rack_id ?? undefined,
location: (row as any).location ?? undefined,
monitor_enabled: row.monitor_enabled ?? false,
ssh_user_override: row.ssh_user_override ?? '',
ssh_port_override: row.ssh_port_override ?? undefined,
})
setEditDevice(row)
setDeviceModal(true)
}
const handleSaveDevice = async () => {
const values = await deviceForm.validateFields()
const payload = stripEmpty(values)
if ('rack_id' in values && values.rack_id === undefined) {
payload.rack_id = null
}
try {
if (editDevice) {
await updateDevice.mutateAsync({ id: editDevice.id, body: payload as any })
message.success('Устройство обновлено')
} else {
await createDevice.mutateAsync(payload as any)
message.success('Устройство добавлено')
}
setDeviceModal(false)
setEditDevice(null)
deviceForm.resetFields()
} catch {
message.error(editDevice ? 'Ошибка при обновлении' : 'Ошибка при добавлении')
}
}
const handleDeleteDevice = async (row: DeviceItem) => {
try {
await deleteDevice.mutateAsync(row.id)
message.success(`Устройство ${row.ip} удалено`)
} catch {
message.error('Ошибка при удалении устройства')
}
}
const handleCsvPreview = async () => {
if (!csvFile) return
try {
const preview = await previewCSV.mutateAsync(csvFile)
setCsvPreview(preview)
} catch {
message.error('Не удалось разобрать CSV')
}
}
const handleCsvImport = async () => {
if (!csvFile) return
try {
const result = await importCSV.mutateAsync(csvFile)
message.success(`Импортировано: +${result.created} создано, ${result.updated} обновлено`)
setCsvModal(false)
setCsvFile(null)
setCsvPreview(null)
} catch {
message.error('Ошибка при импорте CSV')
}
}
const closeCsvModal = () => {
setCsvModal(false)
setCsvFile(null)
setCsvPreview(null)
}
const handleAddZone = async () => {
const values = await zoneForm.validateFields()
try {
await createZone.mutateAsync(stripEmpty(values) as any)
message.success('Зона добавлена')
zoneForm.resetFields()
} catch {
message.error('Ошибка при добавлении зоны')
}
}
const handleAddRack = async () => {
const values = await rackForm.validateFields()
try {
await createRack.mutateAsync(stripEmpty(values) as any)
message.success('Стойка добавлена')
rackForm.resetFields()
} catch {
message.error('Ошибка при добавлении стойки')
}
}
const filteredDevices = useMemo(() => {
if (!searchText) return devices ?? []
const q = searchText.toLowerCase()
return (devices ?? []).filter(
(d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
)
}, [devices, searchText])
if (objLoading) return <Spin />
const deviceColumns = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <code>{ip}</code>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (h: string | null) => h ?? '—',
},
{
title: 'Стойка / локация',
key: 'location',
render: (_: unknown, row: DeviceItem) => {
if (row.rack_name) return <Tag color="blue">{row.rack_name}</Tag>
const loc = (row as any).location
if (loc === 'server_room') return <Tag color="purple">Серверная</Tag>
if (loc === 'street') return <Tag color="orange">Улица</Tag>
return <Typography.Text type="secondary"></Typography.Text>
},
filters: [
{ text: 'Без привязки', value: '__none__' },
{ text: 'Серверная', value: 'server_room' },
{ text: 'Улица', value: 'street' },
...(racks ?? []).map((r) => ({ text: r.name, value: `rack:${r.name}` })),
],
onFilter: (value: unknown, record: DeviceItem) => {
if (value === '__none__') return !record.rack_name && !(record as any).location
if (value === 'server_room') return (record as any).location === 'server_room'
if (value === 'street') return (record as any).location === 'street'
if (typeof value === 'string' && value.startsWith('rack:'))
return record.rack_name === value.slice(5)
return false
},
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (c: string) => <CategoryTag category={c} />,
filters: DEVICE_CATEGORIES.map((c) => ({ text: c.label, value: c.value })),
onFilter: (value: unknown, record: DeviceItem) => record.category === value,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />,
},
{
title: 'Последний ping',
dataIndex: 'last_seen',
key: 'last_seen',
render: (ts: string | null, row: DeviceItem) =>
ts ? (
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
<span>{row.last_ping_ms}ms</span>
</Tooltip>
) : '—',
},
{
title: 'Источник',
dataIndex: 'source',
key: 'source',
render: (s: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{s}</Typography.Text>,
},
{
title: '',
key: 'actions',
width: 100,
render: (_: unknown, row: DeviceItem) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditDevice(row)} />
<Popconfirm
title="Удалить устройство?"
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteDevice(row)}
>
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteDevice.isPending} />
</Popconfirm>
</Space>
),
},
]
const zoneColumns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{ title: 'Описание', dataIndex: 'description', key: 'description', render: (v: string | null) => v ?? '—' },
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: ZoneItem) => (
<Popconfirm
title="Удалить зону?"
description="Стойки останутся, привязка к зоне будет снята."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteZone.mutateAsync(row.id).catch(() => message.error('Ошибка удаления зоны'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
const rackColumns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{ title: 'Зона', dataIndex: 'zone_name', key: 'zone_name', render: (v: string | null) => v ? <Tag>{v}</Tag> : '—' },
{ title: 'Расположение', dataIndex: 'location', key: 'location', render: (v: string | null) => v ?? '—' },
{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, row: RackItem) => (
<Popconfirm
title="Удалить стойку?"
description="Устройства останутся, привязка к стойке будет снята."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]
const previewCreateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
{ title: 'Hostname', dataIndex: 'hostname', key: 'hostname', render: (v: string) => v ?? '—' },
{ title: 'Категория', dataIndex: 'category', key: 'category' },
{ title: 'Роль', dataIndex: 'role', key: 'role' },
]
const previewUpdateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
{
title: 'Изменения',
dataIndex: 'changes',
key: 'changes',
render: (changes: Record<string, { from: unknown; to: unknown }>) =>
Object.entries(changes).map(([k, v]) => `${k}: ${v.from}${v.to}`).join(', ') || '—',
},
]
// Breadcrumb items: Инвентарь → [City →] Object
const breadcrumbItems = [
{ title: <a onClick={() => navigate('/inventory/objects')}>Инвентарь</a> },
...(obj?.city ? [{ title: <a onClick={() => navigate('/inventory/objects')}>{obj.city}</a> }] : []),
{ title: obj?.name },
]
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col flex="auto">
<Descriptions size="small" bordered column={4}>
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
<Descriptions.Item label="БД">{obj?.db_host ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Клиент">{obj?.client ?? '—'}</Descriptions.Item>
</Descriptions>
</Col>
<Col>
<Space wrap>
<Button icon={<SyncOutlined />} loading={syncMutation.isPending} onClick={handleSync}>
Синхронизировать из БД
</Button>
<Button icon={<ApiOutlined />} loading={discoverMutation.isPending} onClick={handleDiscover}>
SSH Discovery
</Button>
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
Импорт CSV
</Button>
<Button icon={<GoogleOutlined />} onClick={() => setSheetsModal(true)}>
Google Sheets
</Button>
<Button icon={<PlusOutlined />} type="primary" onClick={openAddDevice}>
Добавить устройство
</Button>
<Button icon={<FileTextOutlined />} onClick={() => navigate(`/inventory/objects/${objId}/snapshots`)}>
Снапшоты
</Button>
<Button icon={<ThunderboltOutlined />} onClick={() => setRackModal(true)}>
Стойки и зоны
</Button>
</Space>
</Col>
</Row>
<Input.Search
placeholder="Поиск по IP или hostname..."
allowClear
style={{ marginBottom: 12, maxWidth: 360 }}
onChange={(e) => setSearchText(e.target.value)}
/>
<Table
dataSource={filteredDevices}
columns={deviceColumns}
rowKey="id"
loading={devLoading}
size="middle"
pagination={{ pageSize: 50 }}
/>
{/* Rack & Zone Management Modal */}
<Modal
title="Стойки и зоны"
open={rackModal}
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }}
footer={null}
width={820}
>
<Typography.Title level={5} style={{ marginTop: 0 }}>Зоны</Typography.Title>
<Form form={zoneForm} layout="inline" style={{ marginBottom: 12 }}>
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="Название зоны" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="description">
<Input placeholder="Описание (опц.)" style={{ width: 180 }} />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddZone} loading={createZone.isPending}>
Добавить зону
</Button>
</Form.Item>
</Form>
<Table dataSource={zones ?? []} columns={zoneColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: 'Нет зон' }} style={{ marginBottom: 24 }} />
<Divider />
<Typography.Title level={5}>Стойки</Typography.Title>
<Form form={rackForm} layout="inline" style={{ marginBottom: 12 }}>
<Form.Item name="name" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="Название стойки" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="zone_id">
<Select placeholder="Зона (опц.)" allowClear style={{ width: 150 }} options={(zones ?? []).map((z) => ({ value: z.id, label: z.name }))} />
</Form.Item>
<Form.Item name="location">
<Input placeholder="Расположение (опц.)" style={{ width: 150 }} />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddRack} loading={createRack.isPending}>
Добавить стойку
</Button>
</Form.Item>
</Form>
<Table dataSource={racks ?? []} columns={rackColumns} rowKey="id" size="small" pagination={false} locale={{ emptyText: 'Нет стоек' }} />
</Modal>
{/* CSV Import Modal */}
<Modal
title="Импорт устройств из CSV"
open={csvModal}
onCancel={closeCsvModal}
width={700}
footer={
<Space>
<Button onClick={closeCsvModal}>Отмена</Button>
{!csvPreview && <Button onClick={handleCsvPreview} loading={previewCSV.isPending} disabled={!csvFile}>Предпросмотр</Button>}
{csvPreview && <Button type="primary" onClick={handleCsvImport} loading={importCSV.isPending}>Импортировать</Button>}
</Space>
}
>
{!csvPreview ? (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
Формат CSV: <code>ip, hostname, category, role, model, mac, notes</code>
</Typography.Paragraph>
<Upload.Dragger
accept=".csv"
maxCount={1}
beforeUpload={(file) => { setCsvFile(file); return false }}
onRemove={() => setCsvFile(null)}
fileList={csvFile ? [{ uid: '1', name: csvFile.name, status: 'done' } as UploadFile] : []}
>
<p className="ant-upload-drag-icon"><UploadOutlined style={{ fontSize: 32, color: '#1677ff' }} /></p>
<p className="ant-upload-text">Перетащите CSV-файл или нажмите для выбора</p>
</Upload.Dragger>
</>
) : (
<>
{csvPreview.errors.length > 0 && (
<Alert type="warning" style={{ marginBottom: 12 }} message={`${csvPreview.errors.length} строк с ошибками`} description={csvPreview.errors.map((e) => `Строка ${e.row}: ${e.error}`).join('\n')} />
)}
{csvPreview.would_create.length > 0 && (
<>
<Typography.Text strong>Будет создано: {csvPreview.would_create.length}</Typography.Text>
<Table size="small" dataSource={csvPreview.would_create} columns={previewCreateCols} rowKey="ip" pagination={false} style={{ marginTop: 8, marginBottom: 16 }} />
</>
)}
{csvPreview.would_update.length > 0 && (
<>
<Typography.Text strong>Будет обновлено: {csvPreview.would_update.length}</Typography.Text>
<Table size="small" dataSource={csvPreview.would_update} columns={previewUpdateCols} rowKey="ip" pagination={false} style={{ marginTop: 8 }} />
</>
)}
</>
)}
</Modal>
{/* Add / Edit Device Modal */}
<Modal
title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
open={deviceModal}
onCancel={() => { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }}
onOk={handleSaveDevice}
confirmLoading={createDevice.isPending || updateDevice.isPending}
okText={editDevice ? 'Сохранить' : 'Добавить'}
width={640}
>
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Input placeholder="192.168.1.100" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="hostname" label="Hostname">
<Input placeholder="camera-01" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="category" label="Категория" rules={[{ required: true }]}>
<Select placeholder="Выберите категорию" options={DEVICE_CATEGORIES} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="role" label="Роль">
<Select placeholder="Выберите роль" allowClear options={DEVICE_ROLES} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="vendor" label="Производитель">
<Input placeholder="Iptronic, Овен, PAX..." />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="model" label="Модель">
<Input placeholder="IPT-IPL1080S" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="rack_id" label="Стойка">
<Select placeholder="Без стойки" allowClear options={rackOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="location" label="Локация (если не в стойке)">
<Select placeholder="Не задана" allowClear options={DEVICE_LOCATIONS} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="mac" label="MAC">
<Input placeholder="AA:BB:CC:DD:EE:FF" />
</Form.Item>
</Col>
</Row>
<Form.Item name="monitor_enabled" valuePropName="checked">
<Checkbox>Мониторинг (автоматический ping)</Checkbox>
</Form.Item>
<Divider orientation="left" plain>SSH (переопределить настройки объекта)</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="ssh_user_override" label="Логин">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password_override" label="Пароль">
<Input.Password placeholder="••••••••" />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="ssh_port_override" label="Порт">
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
{/* Google Sheets Sync Modal */}
<Modal
title="Синхронизация из Google Sheets"
open={sheetsModal}
onCancel={() => { setSheetsModal(false); sheetsForm.resetFields() }}
onOk={handleSyncSheets}
confirmLoading={syncSheetsMutation.isPending}
okText="Синхронизировать"
>
<Form form={sheetsForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="spreadsheet_id"
label="ID таблицы Google Sheets"
rules={[{ required: true, message: 'Введите ID таблицы' }]}
extra="Часть URL после /spreadsheets/d/ и перед /edit"
>
<Input placeholder="1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms" />
</Form.Item>
<Form.Item name="sheet_name" label="Название листа (необязательно)" extra="По умолчанию используется первый лист">
<Input placeholder="Sheet1" />
</Form.Item>
</Form>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
</Typography.Text>
</Modal>
</div>
)
}

View file

@ -1,8 +1,10 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
import {
AutoComplete,
Button,
Checkbox,
Col,
Collapse,
Divider,
Form,
Input,
@ -11,7 +13,6 @@ import {
Popconfirm,
Row,
Space,
Table,
Tag,
Typography,
message,
@ -21,15 +22,23 @@ import { useNavigate } from 'react-router-dom'
import {
useCreateObject,
useDeleteObject,
useObjectsMeta,
useUpdateObject,
useObjects,
type ObjectItem,
} from '../api/objects'
import { stripEmpty } from '../utils/form'
export function ObjectsPage() {
interface Props {
mode: 'inventory' | 'work'
}
const UNGROUPED = '__none__'
export function ObjectsPage({ mode }: Props) {
const navigate = useNavigate()
const { data: objects, isLoading } = useObjects()
const { data: meta } = useObjectsMeta()
const createObject = useCreateObject()
const updateObject = useUpdateObject()
const deleteObject = useDeleteObject()
@ -39,12 +48,37 @@ export function ObjectsPage() {
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const objectUrl = (id: number) =>
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
const filteredObjects = useMemo(() => {
if (!searchText) return objects ?? []
const q = searchText.toLowerCase()
return (objects ?? []).filter((o) => o.name.toLowerCase().includes(q))
return (objects ?? []).filter(
(o) =>
o.name.toLowerCase().includes(q) ||
(o.city ?? '').toLowerCase().includes(q) ||
(o.client ?? '').toLowerCase().includes(q)
)
}, [objects, searchText])
// Group: city → objects[]. Null city → UNGROUPED key
const cityGroups = useMemo(() => {
const map = new Map<string, ObjectItem[]>()
for (const obj of filteredObjects) {
const key = obj.city ?? UNGROUPED
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(obj)
}
// Sort: named cities first (alphabetical), ungrouped last
const sorted = [...map.entries()].sort(([a], [b]) => {
if (a === UNGROUPED) return 1
if (b === UNGROUPED) return -1
return a.localeCompare(b, 'ru')
})
return sorted
}, [filteredObjects])
const openCreate = () => {
form.resetFields()
setEditTarget(null)
@ -57,6 +91,8 @@ export function ObjectsPage() {
name: row.name,
description: row.description ?? '',
server_ip: row.server_ip ?? '',
city: row.city ?? '',
client: row.client ?? '',
ssh_user: row.ssh_user ?? '',
db_host: row.db_host ?? '',
})
@ -78,7 +114,7 @@ export function ObjectsPage() {
setModalOpen(false)
form.resetFields()
} catch {
message.error(editTarget === null ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта')
message.error(editTarget === null ? 'Ошибка при создании' : 'Ошибка при обновлении')
}
}
@ -88,92 +124,72 @@ export function ObjectsPage() {
message.success(`Объект "${row.name}" удалён`)
} catch (err: any) {
if (err?.response?.status === 409) {
message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства')
message.error('Нельзя удалить объект с устройствами')
} else {
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении объекта')
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении')
}
}
}
const columns = [
{
title: 'Название',
dataIndex: 'name',
key: 'name',
render: (name: string, row: ObjectItem) => (
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/objects/${row.id}`)}>
{name}
</Button>
),
},
{
title: 'Сервер',
dataIndex: 'server_ip',
key: 'server_ip',
render: (ip: string | null) =>
ip ?? <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'SSH',
dataIndex: 'ssh_user',
key: 'ssh_user',
render: (u: string | null) =>
u ? <Tag>{u}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Теги',
dataIndex: 'tags',
key: 'tags',
render: (tags: ObjectItem['tags']) =>
tags.map((t) => (
<Tag key={t.id} color={t.color}>
{t.name}
</Tag>
)),
},
{
title: 'Статус',
dataIndex: 'is_active',
key: 'is_active',
render: (active: boolean) =>
active ? <Tag color="green">Активен</Tag> : <Tag color="default">Неактивен</Tag>,
},
{
title: '',
key: 'actions',
width: 160,
render: (_: unknown, row: ObjectItem) => (
<Space>
<Button size="small" onClick={() => navigate(`/objects/${row.id}`)}>
Устройства
</Button>
const isSubmitting = createObject.isPending || updateObject.isPending
const renderObjectRow = (obj: ObjectItem) => (
<div
key={obj.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
borderBottom: '1px solid #f0f0f0',
gap: 12,
}}
>
<Button
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); openEdit(row) }}
/>
type="link"
style={{ padding: 0, fontWeight: 500, flex: 1, textAlign: 'left' }}
onClick={() => navigate(objectUrl(obj.id))}
>
<RightOutlined style={{ fontSize: 11, marginRight: 6, color: '#999' }} />
{obj.name}
</Button>
{obj.client && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{obj.client}</Typography.Text>}
{obj.server_ip && <Typography.Text code style={{ fontSize: 12 }}>{obj.server_ip}</Typography.Text>}
{obj.tags.map((t) => (
<Tag key={t.id} color={t.color} style={{ margin: 0 }}>{t.name}</Tag>
))}
{!obj.is_active && <Tag color="default">Неактивен</Tag>}
{mode === 'inventory' && (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(obj)} />
<Popconfirm
title="Удалить объект?"
description="Объект можно удалить только если у него нет устройств."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row)}
onConfirm={() => handleDelete(obj)}
>
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
loading={deleteObject.isPending}
/>
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteObject.isPending} />
</Popconfirm>
</Space>
),
},
]
)}
</div>
)
const isSubmitting = createObject.isPending || updateObject.isPending
const collapseItems = cityGroups.map(([city, objs]) => ({
key: city,
label: (
<Space>
<Typography.Text strong>{city === UNGROUPED ? 'Без города' : city}</Typography.Text>
<Tag>{objs.length}</Tag>
</Space>
),
children: (
<div style={{ margin: '-12px -16px' }}>
{objs.map(renderObjectRow)}
</div>
),
}))
return (
<div>
@ -186,30 +202,35 @@ export function ObjectsPage() {
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
Объекты
{mode === 'inventory' ? 'Инвентарь — Объекты' : 'Работа — Объекты'}
</Typography.Title>
<Space>
<Input.Search
placeholder="Поиск по названию..."
placeholder="Поиск по названию, городу, клиенту..."
allowClear
style={{ width: 260 }}
style={{ width: 300 }}
onChange={(e) => setSearchText(e.target.value)}
/>
{mode === 'inventory' && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Добавить
</Button>
)}
</Space>
</div>
<Table
dataSource={filteredObjects}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 20 }}
{isLoading ? (
<Typography.Text type="secondary">Загрузка...</Typography.Text>
) : cityGroups.length === 0 ? (
<Typography.Text type="secondary">Нет объектов</Typography.Text>
) : (
<Collapse
defaultActiveKey={cityGroups.map(([city]) => city)}
items={collapseItems}
/>
)}
{/* Create / Edit modal — only relevant in inventory mode */}
<Modal
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
open={modalOpen}
@ -217,7 +238,7 @@ export function ObjectsPage() {
onOk={handleSubmit}
confirmLoading={isSubmitting}
okText={editTarget === null ? 'Создать' : 'Сохранить'}
width={640}
width={680}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={16}>
@ -233,6 +254,31 @@ export function ObjectsPage() {
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="city" label="Город">
<AutoComplete
options={(meta?.cities ?? []).map((c) => ({ value: c }))}
filterOption={(input, opt) =>
(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())
}
placeholder="Санкт-Петербург"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="client" label="Клиент">
<AutoComplete
options={(meta?.clients ?? []).map((c) => ({ value: c }))}
filterOption={(input, opt) =>
(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())
}
placeholder="Авангард"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Описание">
<Input.TextArea rows={2} />
</Form.Item>
@ -260,7 +306,7 @@ export function ObjectsPage() {
<Divider orientation="left" plain>
Подключение к БД объекта{' '}
<Typography.Text type="secondary" style={{ fontWeight: 400 }}>
(для синхронизации устройств)
(для синхронизации)
</Typography.Text>
</Divider>

View file

@ -0,0 +1,304 @@
import { ThunderboltOutlined } from '@ant-design/icons'
import {
Button,
Col,
Form,
Input,
Modal,
Row,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAllDevices, useObjectsMeta, type DeviceItem } from '../api/objects'
import { useObjects } from '../api/objects'
import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs'
const CATEGORY_OPTIONS = [
{ value: 'main_server', label: 'Главный сервер' },
{ value: 'vm', label: 'Виртуалка / сервис' },
{ value: 'router', label: 'Роутер / шлюз' },
{ value: 'embedded', label: 'Пром-ПК' },
{ value: 'camera', label: 'Камера' },
{ value: 'io_board', label: 'Модуль ввода/вывода' },
{ value: 'bank_terminal', label: 'Банковский терминал' },
{ value: 'cash_register', label: 'ККТ' },
{ value: 'other', label: 'Другое' },
]
export function WorkDevicesPage() {
const navigate = useNavigate()
const { data: meta } = useObjectsMeta()
const { data: allObjects } = useObjects()
const { data: actions } = useActions()
const createTask = useCreateTask()
const [filterCategory, setFilterCategory] = useState<string[]>([])
const [filterCity, setFilterCity] = useState<string | undefined>()
const [filterClient, setFilterClient] = useState<string | undefined>()
const [filterObjectId, setFilterObjectId] = useState<number | undefined>()
const [filterStatus, setFilterStatus] = useState<string | undefined>()
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([])
const [taskModal, setTaskModal] = useState(false)
const [selectedAction, setSelectedAction] = useState<string>('')
const [form] = Form.useForm()
const { data: devices, isLoading } = useAllDevices({
category: filterCategory.length > 0 ? filterCategory : undefined,
city: filterCity,
client: filterClient,
object_id: filterObjectId,
status: filterStatus,
})
const currentAction = actions?.find((a) => a.name === selectedAction)
const openTaskModal = () => {
setTaskModal(true)
setSelectedAction('')
form.resetFields()
}
const submitTask = async () => {
const values = await form.validateFields()
const { action, ...params } = values
// If specific devices selected — run on each; else run with current filters as scope
const scope =
selectedRowKeys.length > 0
? { type: 'device_list', ids: selectedRowKeys }
: {
type: 'filter',
category: filterCategory.length > 0 ? filterCategory : undefined,
city: filterCity,
client: filterClient,
object_id: filterObjectId,
}
try {
const task = await createTask.mutateAsync({ type: action, target_scope: scope, params })
message.success('Задача создана')
setTaskModal(false)
navigate(`/tasks/${task.id}`)
} catch {
message.error('Ошибка создания задачи')
}
}
const objectOptions = (allObjects ?? []).map((o) => ({
value: o.id,
label: o.city ? `${o.city}${o.name}` : o.name,
}))
const columns = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <code>{ip}</code>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (h: string | null) => h ?? '—',
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (c: string) => <CategoryTag category={c} />,
},
{
title: 'Объект',
dataIndex: 'object_id',
key: 'object_id',
render: (objId: number) => {
const obj = (allObjects ?? []).find((o) => o.id === objId)
if (!obj) return objId
return (
<Space size={4}>
{obj.city && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{obj.city}</Typography.Text>}
<Button
type="link"
size="small"
style={{ padding: 0 }}
onClick={() => navigate(`/work/objects/${objId}`)}
>
{obj.name}
</Button>
</Space>
)
},
},
{
title: 'Клиент',
key: 'client',
render: (_: unknown, row: DeviceItem) => {
const obj = (allObjects ?? []).find((o) => o.id === row.object_id)
return obj?.client ? <Tag>{obj.client}</Tag> : <Typography.Text type="secondary"></Typography.Text>
},
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />,
},
{
title: 'Последний ping',
dataIndex: 'last_seen',
key: 'last_seen',
render: (ts: string | null, row: DeviceItem) =>
ts ? (
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
<span>{row.last_ping_ms}ms</span>
</Tooltip>
) : '—',
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Все устройства
</Typography.Title>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={openTaskModal}
disabled={!devices || devices.length === 0}
>
{selectedRowKeys.length > 0
? `Задача на выбранные (${selectedRowKeys.length})`
: 'Задача на выборку'}
</Button>
</div>
{/* Filters */}
<Row gutter={12} style={{ marginBottom: 16 }}>
<Col span={6}>
<Select
mode="multiple"
placeholder="Категория"
style={{ width: '100%' }}
allowClear
options={CATEGORY_OPTIONS}
value={filterCategory}
onChange={setFilterCategory}
maxTagCount="responsive"
/>
</Col>
<Col span={4}>
<Select
placeholder="Город"
style={{ width: '100%' }}
allowClear
options={(meta?.cities ?? []).map((c) => ({ value: c, label: c }))}
value={filterCity}
onChange={setFilterCity}
/>
</Col>
<Col span={4}>
<Select
placeholder="Клиент"
style={{ width: '100%' }}
allowClear
options={(meta?.clients ?? []).map((c) => ({ value: c, label: c }))}
value={filterClient}
onChange={setFilterClient}
/>
</Col>
<Col span={6}>
<Select
placeholder="Объект"
style={{ width: '100%' }}
allowClear
showSearch
optionFilterProp="label"
options={objectOptions}
value={filterObjectId}
onChange={setFilterObjectId}
/>
</Col>
<Col span={4}>
<Select
placeholder="Статус"
style={{ width: '100%' }}
allowClear
options={[
{ value: 'online', label: 'Online' },
{ value: 'offline', label: 'Offline' },
{ value: 'unknown', label: 'Unknown' },
]}
value={filterStatus}
onChange={setFilterStatus}
/>
</Col>
</Row>
{devices && (
<Typography.Text type="secondary" style={{ marginBottom: 8, display: 'block' }}>
Найдено: {devices.length} устройств
</Typography.Text>
)}
<Table
dataSource={devices ?? []}
columns={columns}
rowKey="id"
loading={isLoading}
size="middle"
pagination={{ pageSize: 50 }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
}}
/>
{/* Task Modal */}
<Modal
title="Создать задачу на выборку"
open={taskModal}
onCancel={() => setTaskModal(false)}
onOk={submitTask}
confirmLoading={createTask.isPending}
okText="Запустить"
>
{selectedRowKeys.length > 0 ? (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Выбрано устройств: {selectedRowKeys.length}
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Задача будет запущена на все устройства из текущей выборки ({devices?.length ?? 0} шт.)
</Typography.Text>
)}
<Form form={form} layout="vertical">
<Form.Item name="action" label="Действие" rules={[{ required: true }]}>
<Select
placeholder="Выберите действие"
options={actions?.map((a) => ({ value: a.name, label: a.display_name }))}
onChange={setSelectedAction}
/>
</Form.Item>
{currentAction?.params_schema.map((p) => (
<Form.Item key={p.name} name={p.name} label={p.label} rules={p.required ? [{ required: true }] : []}>
<Input />
</Form.Item>
))}
</Form>
</Modal>
</div>
)
}

View file

@ -0,0 +1,254 @@
import {
PlayCircleOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import {
Breadcrumb,
Button,
Col,
Descriptions,
Form,
Input,
Modal,
Row,
Select,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useDevices, useObject, type DeviceItem } from '../api/objects'
import { useRacks } from '../api/racks'
import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import dayjs from 'dayjs'
export function WorkObjectDetailPage() {
const { id } = useParams<{ id: string }>()
const objId = Number(id)
const navigate = useNavigate()
const { data: obj, isLoading: objLoading } = useObject(objId)
const { data: devices, isLoading: devLoading } = useDevices(objId)
const { data: racks } = useRacks(objId)
const { data: actions } = useActions()
const createTask = useCreateTask()
const [taskModal, setTaskModal] = useState<{ scope: object; title: string } | null>(null)
const [selectedAction, setSelectedAction] = useState<string>('')
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm()
const currentAction = actions?.find((a) => a.name === selectedAction)
const openTaskModal = (scope: object, title: string) => {
setTaskModal({ scope, title })
setSelectedAction('')
form.resetFields()
}
const submitTask = async () => {
const values = await form.validateFields()
const { action, ...params } = values
try {
const task = await createTask.mutateAsync({
type: action,
target_scope: taskModal!.scope,
params,
})
message.success('Задача создана')
setTaskModal(null)
navigate(`/tasks/${task.id}`)
} catch {
message.error('Ошибка создания задачи')
}
}
const filteredDevices = useMemo(() => {
if (!searchText) return devices ?? []
const q = searchText.toLowerCase()
return (devices ?? []).filter(
(d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
)
}, [devices, searchText])
if (objLoading) return <Spin />
const deviceColumns = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <code>{ip}</code>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (h: string | null) => h ?? '—',
},
{
title: 'Стойка',
dataIndex: 'rack_name',
key: 'rack_name',
render: (name: string | null) =>
name ? <Tag color="blue">{name}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
filters: [
{ text: 'Без стойки', value: '__none__' },
...(racks ?? []).map((r) => ({ text: r.name, value: r.name })),
],
onFilter: (value: unknown, record: DeviceItem) => {
if (value === '__none__') return record.rack_name === null
return record.rack_name === value
},
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (c: string) => <CategoryTag category={c} />,
filters: [
{ text: 'Главный сервер', value: 'main_server' },
{ text: 'Виртуалка', value: 'vm' },
{ text: 'Роутер', value: 'router' },
{ text: 'Пром-ПК', value: 'embedded' },
{ text: 'Камера', value: 'camera' },
{ text: 'Модуль В/В', value: 'io_board' },
{ text: 'Банк. терминал', value: 'bank_terminal' },
{ text: 'ККТ', value: 'cash_register' },
{ text: 'Другое', value: 'other' },
],
onFilter: (value: unknown, record: DeviceItem) => record.category === value,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
render: (s: string) => <DeviceStatusBadge status={s} />,
},
{
title: 'Последний ping',
dataIndex: 'last_seen',
key: 'last_seen',
render: (ts: string | null, row: DeviceItem) =>
ts ? (
<Tooltip title={dayjs(ts).format('DD.MM.YYYY HH:mm:ss')}>
<span>{row.last_ping_ms}ms</span>
</Tooltip>
) : '—',
},
{
title: '',
key: 'actions',
width: 110,
render: (_: unknown, row: DeviceItem) => (
<Button
size="small"
icon={<PlayCircleOutlined />}
onClick={() => openTaskModal({ type: 'device', id: row.id }, `Задача: ${row.hostname ?? row.ip}`)}
>
Задача
</Button>
),
},
]
// Breadcrumb: Работа → [City →] Object
const breadcrumbItems = [
{ title: <a onClick={() => navigate('/work/objects')}>Работа</a> },
...(obj?.city ? [{ title: <a onClick={() => navigate('/work/objects')}>{obj.city}</a> }] : []),
{ title: obj?.name },
]
return (
<div>
<Breadcrumb style={{ marginBottom: 16 }} items={breadcrumbItems} />
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col flex="auto">
<Descriptions size="small" bordered column={3}>
<Descriptions.Item label="Сервер">{obj?.server_ip ?? '—'}</Descriptions.Item>
<Descriptions.Item label="Клиент">{obj?.client ?? '—'}</Descriptions.Item>
<Descriptions.Item label="SSH">{obj?.ssh_user ?? '—'}</Descriptions.Item>
</Descriptions>
</Col>
<Col>
<Space>
<Button
icon={<ThunderboltOutlined />}
onClick={() => openTaskModal({ type: 'object', id: objId }, 'Задача на весь объект')}
>
Задача на объект
</Button>
<Button
onClick={() =>
openTaskModal(
{ type: 'category', object_id: objId, category: 'main_server' },
'Задача: все серверы'
)
}
>
Все серверы
</Button>
<Button
onClick={() =>
openTaskModal(
{ type: 'category', object_id: objId, category: 'bank_terminal' },
'Задача: все терминалы'
)
}
>
Все терминалы
</Button>
</Space>
</Col>
</Row>
<Input.Search
placeholder="Поиск по IP или hostname..."
allowClear
style={{ marginBottom: 12, maxWidth: 360 }}
onChange={(e) => setSearchText(e.target.value)}
/>
<Table
dataSource={filteredDevices}
columns={deviceColumns}
rowKey="id"
loading={devLoading}
size="middle"
pagination={{ pageSize: 50 }}
/>
{/* Task Modal */}
<Modal
title={taskModal?.title ?? 'Создать задачу'}
open={!!taskModal}
onCancel={() => setTaskModal(null)}
onOk={submitTask}
confirmLoading={createTask.isPending}
okText="Запустить"
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="action" label="Действие" rules={[{ required: true }]}>
<Select
placeholder="Выберите действие"
options={actions?.map((a) => ({ value: a.name, label: a.display_name }))}
onChange={setSelectedAction}
/>
</Form.Item>
{currentAction?.params_schema.map((p) => (
<Form.Item key={p.name} name={p.name} label={p.label} rules={p.required ? [{ required: true }] : []}>
<Input />
</Form.Item>
))}
</Form>
</Modal>
</div>
)
}