This commit is contained in:
dv 2026-04-07 11:55:28 +03:00
parent 86c02eb2e9
commit e8fd5a2516
26 changed files with 1029 additions and 91 deletions

View file

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(xargs grep:*)"
]
}
}

View file

@ -0,0 +1,59 @@
"""add racks table and rack_id to devices
Revision ID: 0003
Revises: 0002
Create Date: 2026-04-07
"""
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"racks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"object_id",
sa.Integer(),
sa.ForeignKey("objects.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("location", sa.String(200), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
)
op.add_column(
"devices",
sa.Column(
"rack_id",
sa.Integer(),
sa.ForeignKey("racks.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
)
def downgrade() -> None:
op.drop_column("devices", "rack_id")
op.drop_table("racks")

View file

@ -0,0 +1,58 @@
"""add zones table and zone_id to racks
Revision ID: 0004
Revises: 0003
Create Date: 2026-04-07
"""
from alembic import op
import sqlalchemy as sa
revision = "0004"
down_revision = "0003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"zones",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"object_id",
sa.Integer(),
sa.ForeignKey("objects.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("NOW()"),
nullable=False,
),
)
op.add_column(
"racks",
sa.Column(
"zone_id",
sa.Integer(),
sa.ForeignKey("zones.id", ondelete="SET NULL"),
nullable=True,
index=True,
),
)
def downgrade() -> None:
op.drop_column("racks", "zone_id")
op.drop_table("zones")

View file

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

View file

@ -57,7 +57,15 @@ class Device(Base, TimestampMixin):
last_ping_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) last_ping_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown") status: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown")
rack_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("racks.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True) notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
object: Mapped["Object"] = relationship("Object", back_populates="devices") object: Mapped["Object"] = relationship("Object", back_populates="devices")
rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices")

View file

@ -46,4 +46,10 @@ class Object(Base, TimestampMixin):
devices: Mapped[list["Device"]] = relationship( devices: Mapped[list["Device"]] = relationship(
"Device", back_populates="object", cascade="all, delete-orphan" "Device", back_populates="object", cascade="all, delete-orphan"
) )
racks: Mapped[list["Rack"]] = relationship(
"Rack", back_populates="object", cascade="all, delete-orphan"
)
zones: Mapped[list["Zone"]] = relationship(
"Zone", back_populates="object", cascade="all, delete-orphan"
)
tags: Mapped[list["Tag"]] = relationship("Tag", secondary=object_tags) tags: Mapped[list["Tag"]] = relationship("Tag", secondary=object_tags)

View file

@ -0,0 +1,32 @@
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
class Rack(Base, TimestampMixin):
__tablename__ = "racks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
object_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("objects.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
location: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
zone_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("zones.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
object: Mapped["Object"] = relationship("Object", back_populates="racks")
zone: Mapped[Optional["Zone"]] = relationship("Zone", back_populates="racks")
devices: Mapped[list["Device"]] = relationship("Device", back_populates="rack")

View file

@ -0,0 +1,23 @@
from typing import Optional
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
class Zone(Base, TimestampMixin):
__tablename__ = "zones"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
object_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("objects.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
object: Mapped["Object"] = relationship("Object", back_populates="zones")
racks: Mapped[list["Rack"]] = relationship("Rack", back_populates="zone")

View file

@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
@ -52,7 +53,11 @@ async def list_devices(
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
): ):
await _get_object(db, obj_id) await _get_object(db, obj_id)
query = select(Device).where(Device.object_id == obj_id) query = (
select(Device)
.options(selectinload(Device.rack))
.where(Device.object_id == obj_id)
)
if category: if category:
query = query.where(Device.category == category) query = query.where(Device.category == category)
if role: if role:
@ -63,7 +68,8 @@ async def list_devices(
query = query.where(Device.is_active == is_active) query = query.where(Device.is_active == is_active)
query = query.order_by(Device.ip) query = query.order_by(Device.ip)
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() devices = result.scalars().all()
return [DeviceRead.from_orm_with_rack(d) for d in devices]
@router.post("", response_model=DeviceRead, status_code=201) @router.post("", response_model=DeviceRead, status_code=201)
@ -84,7 +90,11 @@ async def create_device(
db.add(device) db.add(device)
await db.flush() await db.flush()
await db.refresh(device) await db.refresh(device)
return device # reload with rack relationship
result = await db.execute(
select(Device).options(selectinload(Device.rack)).where(Device.id == device.id)
)
return DeviceRead.from_orm_with_rack(result.scalar_one())
@router.post("/import/preview", response_model=DeviceImportPreview) @router.post("/import/preview", response_model=DeviceImportPreview)
@ -199,7 +209,15 @@ async def get_device(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
): ):
return await _get_device(db, obj_id, device_id) result = await db.execute(
select(Device)
.options(selectinload(Device.rack))
.where(Device.id == device_id, Device.object_id == obj_id)
)
device = result.scalar_one_or_none()
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
return DeviceRead.from_orm_with_rack(device)
@router.patch("/{device_id}", response_model=DeviceRead) @router.patch("/{device_id}", response_model=DeviceRead)

View file

@ -0,0 +1,93 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.object import Object
from app.models.rack import Rack
from app.models.user import User
from app.schemas.rack import RackCreate, RackRead, RackUpdate
router = APIRouter(prefix="/api/v1/objects/{obj_id}/racks", tags=["racks"])
async def _get_object(db: AsyncSession, obj_id: int) -> Object:
obj = await db.get(Object, obj_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return obj
async def _load_rack(db: AsyncSession, obj_id: int, rack_id: int) -> Rack:
result = await db.execute(
select(Rack)
.options(selectinload(Rack.zone))
.where(Rack.id == rack_id, Rack.object_id == obj_id)
)
rack = result.scalar_one_or_none()
if rack is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rack not found")
return rack
@router.get("", response_model=list[RackRead])
async def list_racks(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
result = await db.execute(
select(Rack)
.options(selectinload(Rack.zone))
.where(Rack.object_id == obj_id)
.order_by(Rack.name)
)
return [RackRead.from_orm_with_zone(r) for r in result.scalars().all()]
@router.post("", response_model=RackRead, status_code=status.HTTP_201_CREATED)
async def create_rack(
obj_id: int,
body: RackCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
rack = Rack(object_id=obj_id, **body.model_dump())
db.add(rack)
await db.flush()
result = await db.execute(
select(Rack).options(selectinload(Rack.zone)).where(Rack.id == rack.id)
)
return RackRead.from_orm_with_zone(result.scalar_one())
@router.patch("/{rack_id}", response_model=RackRead)
async def update_rack(
obj_id: int,
rack_id: int,
body: RackUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
rack = await _load_rack(db, obj_id, rack_id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(rack, field, value)
await db.flush()
result = await db.execute(
select(Rack).options(selectinload(Rack.zone)).where(Rack.id == rack.id)
)
return RackRead.from_orm_with_zone(result.scalar_one())
@router.delete("/{rack_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_rack(
obj_id: int,
rack_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
rack = await _load_rack(db, obj_id, rack_id)
await db.delete(rack)

View file

@ -4,7 +4,11 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
from app.models.device import Device
from app.models.object import Object
from app.models.rack import Rack
from app.models.task import Task from app.models.task import Task
from app.models.zone import Zone
from app.models.user import User from app.models.user import User
from app.plugins.registry import action_registry from app.plugins.registry import action_registry
from app.schemas.task import ActionInfo, TaskCreate, TaskDetail, TaskRead from app.schemas.task import ActionInfo, TaskCreate, TaskDetail, TaskRead
@ -13,6 +17,74 @@ from app.services import task as task_service
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
async def _resolve_labels(db: AsyncSession, tasks: list[Task]) -> dict[str, str]:
"""Batch-resolve target_scope to human-readable labels."""
obj_ids: set[int] = set()
dev_ids: set[int] = set()
rack_ids: set[int] = set()
zone_ids: set[int] = set()
for t in tasks:
scope = t.target_scope
kind = scope.get("type")
if kind == "object":
obj_ids.add(scope["id"])
elif kind == "device":
dev_ids.add(scope["id"])
elif kind == "rack":
rack_ids.add(scope["id"])
elif kind == "zone":
zone_ids.add(scope["id"])
elif kind in ("category", "role") and (oid := scope.get("object_id")):
obj_ids.add(oid)
objs: dict[int, str] = {}
devs: dict[int, str] = {}
rack_names: dict[int, str] = {}
zone_names: dict[int, str] = {}
if obj_ids:
rows = await db.execute(select(Object.id, Object.name).where(Object.id.in_(obj_ids)))
objs = {row[0]: row[1] for row in rows.all()}
if dev_ids:
rows = await db.execute(
select(Device.id, Device.hostname, Device.ip).where(Device.id.in_(dev_ids))
)
devs = {row[0]: (row[1] or row[2]) for row in rows.all()}
if rack_ids:
rows = await db.execute(select(Rack.id, Rack.name).where(Rack.id.in_(rack_ids)))
rack_names = {row[0]: row[1] for row in rows.all()}
if zone_ids:
rows = await db.execute(select(Zone.id, Zone.name).where(Zone.id.in_(zone_ids)))
zone_names = {row[0]: row[1] for row in rows.all()}
labels: dict[str, str] = {}
for t in tasks:
scope = t.target_scope
kind = scope.get("type", "")
if kind == "object":
labels[t.id] = objs.get(scope.get("id"), f"Объект #{scope.get('id')}")
elif kind == "device":
labels[t.id] = devs.get(scope.get("id"), f"Устройство #{scope.get('id')}")
elif kind == "rack":
labels[t.id] = f"Стойка: {rack_names.get(scope.get('id'), f'#{scope.get(\"id\")}')}"
elif kind == "zone":
labels[t.id] = f"Зона: {zone_names.get(scope.get('id'), f'#{scope.get(\"id\")}')}"
elif kind in ("category", "role"):
val = scope.get("value", "?")
if oid := scope.get("object_id"):
labels[t.id] = f"{val} @ {objs.get(oid, f'#{oid}')}"
else:
labels[t.id] = f"{val} (все объекты)"
else:
labels[t.id] = ""
return labels
@router.get("/actions", response_model=list[ActionInfo]) @router.get("/actions", response_model=list[ActionInfo])
async def list_actions(_: User = Depends(get_current_user)): async def list_actions(_: User = Depends(get_current_user)):
return [ return [
@ -30,6 +102,7 @@ async def list_actions(_: User = Depends(get_current_user)):
async def create_task( async def create_task(
body: TaskCreate, body: TaskCreate,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
): ):
try: try:
task = await task_service.create_task( task = await task_service.create_task(
@ -40,20 +113,30 @@ async def create_task(
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
return task labels = await _resolve_labels(db, [task])
result = TaskRead.model_validate(task)
result.target_label = labels.get(task.id)
return result
@router.get("", response_model=list[TaskRead]) @router.get("", response_model=list[TaskRead])
async def list_tasks( async def list_tasks(
limit: int = 50, limit: int = 50,
offset: int = 0, skip: int = 0,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
): ):
result = await db.execute( rows = await db.execute(
select(Task).order_by(desc(Task.created_at)).offset(offset).limit(limit) select(Task).order_by(desc(Task.created_at)).offset(skip).limit(limit)
) )
return result.scalars().all() tasks = list(rows.scalars().all())
labels = await _resolve_labels(db, tasks)
result = []
for t in tasks:
r = TaskRead.model_validate(t)
r.target_label = labels.get(t.id)
result.append(r)
return result
@router.get("/{task_id}", response_model=TaskDetail) @router.get("/{task_id}", response_model=TaskDetail)
@ -62,12 +145,15 @@ async def get_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: User = Depends(get_current_user),
): ):
result = await db.execute( rows = await db.execute(
select(Task) select(Task)
.options(selectinload(Task.events), selectinload(Task.device_results)) .options(selectinload(Task.events), selectinload(Task.device_results))
.where(Task.id == task_id) .where(Task.id == task_id)
) )
task = result.scalar_one_or_none() task = rows.scalar_one_or_none()
if task is None: if task is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task labels = await _resolve_labels(db, [task])
result = TaskDetail.model_validate(task)
result.target_label = labels.get(task.id)
return result

View file

@ -0,0 +1,83 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.object import Object
from app.models.zone import Zone
from app.models.user import User
from app.schemas.zone import ZoneCreate, ZoneRead, ZoneUpdate
router = APIRouter(prefix="/api/v1/objects/{obj_id}/zones", tags=["zones"])
async def _get_object(db: AsyncSession, obj_id: int) -> Object:
obj = await db.get(Object, obj_id)
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return obj
async def _get_zone(db: AsyncSession, obj_id: int, zone_id: int) -> Zone:
result = await db.execute(
select(Zone).where(Zone.id == zone_id, Zone.object_id == obj_id)
)
zone = result.scalar_one_or_none()
if zone is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Zone not found")
return zone
@router.get("", response_model=list[ZoneRead])
async def list_zones(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
result = await db.execute(
select(Zone).where(Zone.object_id == obj_id).order_by(Zone.name)
)
return result.scalars().all()
@router.post("", response_model=ZoneRead, status_code=status.HTTP_201_CREATED)
async def create_zone(
obj_id: int,
body: ZoneCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
zone = Zone(object_id=obj_id, **body.model_dump())
db.add(zone)
await db.flush()
await db.refresh(zone)
return zone
@router.patch("/{zone_id}", response_model=ZoneRead)
async def update_zone(
obj_id: int,
zone_id: int,
body: ZoneUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
zone = await _get_zone(db, obj_id, zone_id)
for field, value in body.model_dump(exclude_none=True).items():
setattr(zone, field, value)
await db.flush()
await db.refresh(zone)
return zone
@router.delete("/{zone_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_zone(
obj_id: int,
zone_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
zone = await _get_zone(db, obj_id, zone_id)
await db.delete(zone)

View file

@ -15,6 +15,7 @@ class DeviceCreate(BaseModel):
model: Optional[str] = None model: Optional[str] = None
firmware_version: Optional[str] = None firmware_version: Optional[str] = None
source: str = "manual" source: str = "manual"
rack_id: Optional[int] = None
ssh_user_override: Optional[str] = None ssh_user_override: Optional[str] = None
ssh_password_override: Optional[str] = None # plaintext, encrypted on write ssh_password_override: Optional[str] = None # plaintext, encrypted on write
ssh_port_override: Optional[int] = None ssh_port_override: Optional[int] = None
@ -46,6 +47,7 @@ class DeviceUpdate(BaseModel):
role: Optional[str] = None role: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
firmware_version: Optional[str] = None firmware_version: Optional[str] = None
rack_id: Optional[int] = None
ssh_user_override: Optional[str] = None ssh_user_override: Optional[str] = None
ssh_password_override: Optional[str] = None ssh_password_override: Optional[str] = None
ssh_port_override: Optional[int] = None ssh_port_override: Optional[int] = None
@ -67,6 +69,8 @@ class DeviceRead(BaseModel):
firmware_version: Optional[str] = None firmware_version: Optional[str] = None
source: str source: str
external_id: Optional[str] = None external_id: Optional[str] = None
rack_id: Optional[int] = None
rack_name: Optional[str] = None
ssh_user_override: Optional[str] = None ssh_user_override: Optional[str] = None
ssh_port_override: Optional[int] = None ssh_port_override: Optional[int] = None
device_meta: dict[str, Any] = {} device_meta: dict[str, Any] = {}
@ -82,6 +86,13 @@ class DeviceRead(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@classmethod
def from_orm_with_rack(cls, device: Any) -> "DeviceRead":
obj = cls.model_validate(device)
if device.rack is not None:
obj.rack_name = device.rack.name
return obj
class DeviceImportRow(BaseModel): class DeviceImportRow(BaseModel):
ip: str ip: str

View file

@ -0,0 +1,38 @@
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel
class RackCreate(BaseModel):
name: str
description: Optional[str] = None
location: Optional[str] = None
zone_id: Optional[int] = None
class RackUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
location: Optional[str] = None
zone_id: Optional[int] = None
class RackRead(BaseModel):
id: int
object_id: int
name: str
description: Optional[str] = None
location: Optional[str] = None
zone_id: Optional[int] = None
zone_name: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
@classmethod
def from_orm_with_zone(cls, rack: Any) -> "RackRead":
obj = cls.model_validate(rack)
if rack.zone is not None:
obj.zone_name = rack.zone.name
return obj

View file

@ -38,6 +38,7 @@ class TaskRead(BaseModel):
type: str type: str
status: str status: str
target_scope: dict target_scope: dict
target_label: Optional[str] = None
params: dict params: dict
result: Optional[dict] = None result: Optional[dict] = None
error_message: Optional[str] = None error_message: Optional[str] = None

View file

@ -0,0 +1,24 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class ZoneCreate(BaseModel):
name: str
description: Optional[str] = None
class ZoneUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ZoneRead(BaseModel):
id: int
object_id: int
name: str
description: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}

View file

@ -99,10 +99,7 @@ async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
f"host=127.0.0.1 port={local_port} dbname={obj.db_name} " f"host=127.0.0.1 port={local_port} dbname={obj.db_name} "
f"user={obj.db_user} password={db_password} connect_timeout=10" f"user={obj.db_user} password={db_password} connect_timeout=10"
) )
try:
remote_rows = await _fetch_rows(dsn, obj.db_table) remote_rows = await _fetch_rows(dsn, obj.db_table)
finally:
listener.close()
except Exception as exc: except Exception as exc:
result.errors.append(f"SSH tunnel failed: {exc}") result.errors.append(f"SSH tunnel failed: {exc}")
return result return result

View file

@ -16,6 +16,7 @@ from sqlalchemy.orm import selectinload
from app.models.device import Device from app.models.device import Device
from app.models.object import Object from app.models.object import Object
from app.models.rack import Rack
from app.models.task import Task, TaskEvent, TaskResult from app.models.task import Task, TaskEvent, TaskResult
from app.models.user import User from app.models.user import User
from app.plugins.registry import ActionRegistry, action_registry from app.plugins.registry import ActionRegistry, action_registry
@ -50,8 +51,21 @@ async def _resolve_scope(scope: dict) -> list[tuple[Device, Object]]:
query = query.where(Device.id == scope["id"]) query = query.where(Device.id == scope["id"])
elif scope_type == "object": elif scope_type == "object":
query = query.where(Device.object_id == scope["id"]) query = query.where(Device.object_id == scope["id"])
elif scope_type == "rack":
query = query.where(Device.rack_id == scope["id"])
elif scope_type == "zone":
# Expand zone → rack IDs → devices
rack_rows = await db.execute(
select(Rack.id).where(Rack.zone_id == scope["id"])
)
rack_ids = [r[0] for r in rack_rows.all()]
if not rack_ids:
return []
query = query.where(Device.rack_id.in_(rack_ids))
elif scope_type == "category": elif scope_type == "category":
query = query.where(Device.category == scope["value"]) query = query.where(Device.category == scope["value"])
if "object_id" in scope:
query = query.where(Device.object_id == scope["object_id"])
elif scope_type == "role": elif scope_type == "role":
query = query.where(Device.role == scope["value"]) query = query.where(Device.role == scope["value"])
if "object_id" in scope: if "object_id" in scope:

View file

@ -23,6 +23,10 @@ export interface DeviceItem {
role: string role: string
model: string | null model: string | null
source: string source: string
rack_id: number | null
rack_name: string | null
ssh_user_override: string | null
ssh_port_override: number | null
status: string status: string
last_seen: string | null last_seen: string | null
last_ping_ms: number | null last_ping_ms: number | null
@ -124,9 +128,10 @@ export interface DeviceCreatePayload {
category: string category: string
role: string role: string
model?: string model?: string
ssh_user?: string rack_id?: number | null
ssh_password?: string ssh_user_override?: string
ssh_port?: number ssh_password_override?: string
ssh_port_override?: number
notes?: string notes?: string
} }
@ -139,6 +144,15 @@ export const useCreateDevice = (objId: number) => {
}) })
} }
export const useUpdateDevice = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Partial<DeviceCreatePayload> }) =>
api.patch<DeviceItem>(`/api/v1/objects/${objId}/devices/${id}`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }),
})
}
export const useDeleteDevice = (objId: number) => { export const useDeleteDevice = (objId: number) => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({

49
frontend/src/api/racks.ts Normal file
View file

@ -0,0 +1,49 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
export interface RackItem {
id: number
object_id: number
name: string
description: string | null
location: string | null
zone_id: number | null
zone_name: string | null
created_at: string
}
export const useRacks = (objId: number) =>
useQuery({
queryKey: ['racks', objId],
queryFn: () =>
api.get<RackItem[]>(`/api/v1/objects/${objId}/racks`).then((r) => r.data),
})
export const useCreateRack = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { name: string; description?: string; location?: string; zone_id?: number | null }) =>
api.post<RackItem>(`/api/v1/objects/${objId}/racks`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }),
})
}
export const useUpdateRack = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Partial<{ name: string; description: string; location: string; zone_id: number | null }> }) =>
api.patch<RackItem>(`/api/v1/objects/${objId}/racks/${id}`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['racks', objId] }),
})
}
export const useDeleteRack = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/objects/${objId}/racks/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['racks', objId] })
qc.invalidateQueries({ queryKey: ['devices', objId] })
},
})
}

View file

@ -24,6 +24,7 @@ export interface Task {
type: string type: string
status: string status: string
target_scope: Record<string, unknown> target_scope: Record<string, unknown>
target_label: string | null
params: Record<string, unknown> params: Record<string, unknown>
result: { total: number; ok: number; failed: number } | null result: { total: number; ok: number; failed: number } | null
error_message: string | null error_message: string | null

38
frontend/src/api/zones.ts Normal file
View file

@ -0,0 +1,38 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
export interface ZoneItem {
id: number
object_id: number
name: string
description: string | null
created_at: string
}
export const useZones = (objId: number) =>
useQuery({
queryKey: ['zones', objId],
queryFn: () =>
api.get<ZoneItem[]>(`/api/v1/objects/${objId}/zones`).then((r) => r.data),
})
export const useCreateZone = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { name: string; description?: string }) =>
api.post<ZoneItem>(`/api/v1/objects/${objId}/zones`, body).then((r) => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['zones', objId] }),
})
}
export const useDeleteZone = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: number) => api.delete(`/api/v1/objects/${objId}/zones/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['zones', objId] })
// racks whose zone_id pointed here now have zone_name=null — refresh
qc.invalidateQueries({ queryKey: ['racks', objId] })
},
})
}

View file

@ -1,5 +1,6 @@
import { import {
DeleteOutlined, DeleteOutlined,
EditOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
SyncOutlined, SyncOutlined,
@ -30,11 +31,12 @@ import {
message, message,
} from 'antd' } from 'antd'
import type { UploadFile } from 'antd' import type { UploadFile } from 'antd'
import { useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { import {
useCreateDevice, useCreateDevice,
useDeleteDevice, useDeleteDevice,
useUpdateDevice,
useDevices, useDevices,
useImportCSV, useImportCSV,
useObject, useObject,
@ -43,8 +45,11 @@ import {
type DeviceImportPreview, type DeviceImportPreview,
type DeviceItem, type DeviceItem,
} from '../api/objects' } from '../api/objects'
import { useRacks, useCreateRack, useDeleteRack, type RackItem } from '../api/racks'
import { useZones, useCreateZone, useDeleteZone, type ZoneItem } from '../api/zones'
import { useActions, useCreateTask } from '../api/tasks' import { useActions, useCreateTask } from '../api/tasks'
import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge'
import { stripEmpty } from '../utils/form'
import dayjs from 'dayjs' import dayjs from 'dayjs'
export function ObjectDetailPage() { export function ObjectDetailPage() {
@ -54,27 +59,43 @@ export function ObjectDetailPage() {
const { data: obj, isLoading: objLoading } = useObject(objId) const { data: obj, isLoading: objLoading } = useObject(objId)
const { data: devices, isLoading: devLoading } = useDevices(objId) const { data: devices, isLoading: devLoading } = useDevices(objId)
const { data: racks } = useRacks(objId)
const { data: zones } = useZones(objId)
const syncMutation = useSyncObject(objId) const syncMutation = useSyncObject(objId)
const { data: actions } = useActions() const { data: actions } = useActions()
const createTask = useCreateTask() const createTask = useCreateTask()
const createDevice = useCreateDevice(objId) const createDevice = useCreateDevice(objId)
const updateDevice = useUpdateDevice(objId)
const deleteDevice = useDeleteDevice(objId) const deleteDevice = useDeleteDevice(objId)
const previewCSV = usePreviewCSV(objId) const previewCSV = usePreviewCSV(objId)
const importCSV = useImportCSV(objId) const importCSV = useImportCSV(objId)
const createRack = useCreateRack(objId)
const deleteRack = useDeleteRack(objId)
const createZone = useCreateZone(objId)
const deleteZone = useDeleteZone(objId)
const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null) const [taskModal, setTaskModal] = useState<{ scope: object } | null>(null)
const [deviceModal, setDeviceModal] = useState(false) const [deviceModal, setDeviceModal] = useState(false)
const [editDevice, setEditDevice] = useState<DeviceItem | null>(null)
const [csvModal, setCsvModal] = useState(false) const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null) const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null) const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(null)
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [form] = Form.useForm() const [form] = Form.useForm()
const [deviceForm] = Form.useForm() const [deviceForm] = Form.useForm()
const [rackForm] = Form.useForm()
const [zoneForm] = Form.useForm()
const [selectedAction, setSelectedAction] = useState<string>('') const [selectedAction, setSelectedAction] = useState<string>('')
const currentAction = actions?.find((a) => a.name === selectedAction) const currentAction = actions?.find((a) => a.name === selectedAction)
const rackOptions = useMemo(
() => (racks ?? []).map((r) => ({ value: r.id, label: r.name })),
[racks]
)
const openTaskModal = (scope: object) => { const openTaskModal = (scope: object) => {
setTaskModal({ scope }) setTaskModal({ scope })
setSelectedAction('') setSelectedAction('')
@ -107,18 +128,49 @@ export function ObjectDetailPage() {
} }
} }
const handleAddDevice = async () => { 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,
model: row.model ?? '',
mac: row.mac ?? '',
rack_id: row.rack_id ?? undefined,
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 values = await deviceForm.validateFields()
const payload = Object.fromEntries( const payload = stripEmpty(values)
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined) // rack_id=null means "detach rack" — allow explicit null
) if ('rack_id' in values && values.rack_id === undefined) {
payload.rack_id = null
}
try { try {
if (editDevice) {
await updateDevice.mutateAsync({ id: editDevice.id, body: payload as any })
message.success('Устройство обновлено')
} else {
await createDevice.mutateAsync(payload as any) await createDevice.mutateAsync(payload as any)
message.success('Устройство добавлено') message.success('Устройство добавлено')
}
setDeviceModal(false) setDeviceModal(false)
setEditDevice(null)
deviceForm.resetFields() deviceForm.resetFields()
} catch { } catch {
message.error('Ошибка при добавлении устройства') message.error(editDevice ? 'Ошибка при обновлении устройства' : 'Ошибка при добавлении устройства')
} }
} }
@ -160,15 +212,41 @@ export function ObjectDetailPage() {
setCsvPreview(null) setCsvPreview(null)
} }
const filteredDevices = (devices ?? []).filter((d) => { const handleAddZone = async () => {
if (!searchText) return true const values = await zoneForm.validateFields()
const payload = stripEmpty(values)
try {
await createZone.mutateAsync(payload as any)
message.success('Зона добавлена')
zoneForm.resetFields()
} catch {
message.error('Ошибка при добавлении зоны')
}
}
const handleAddRack = async () => {
const values = await rackForm.validateFields()
const payload = stripEmpty(values)
try {
await createRack.mutateAsync(payload as any)
message.success('Стойка добавлена')
rackForm.resetFields()
} catch {
message.error('Ошибка при добавлении стойки')
}
}
const filteredDevices = useMemo(() => {
if (!searchText) return devices ?? []
const q = searchText.toLowerCase() const q = searchText.toLowerCase()
return d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q) return (devices ?? []).filter(
}) (d) => d.ip.toLowerCase().includes(q) || (d.hostname ?? '').toLowerCase().includes(q)
)
}, [devices, searchText])
if (objLoading) return <Spin /> if (objLoading) return <Spin />
const columns = [ const deviceColumns = [
{ {
title: 'IP', title: 'IP',
dataIndex: 'ip', dataIndex: 'ip',
@ -181,6 +259,21 @@ export function ObjectDetailPage() {
key: 'hostname', key: 'hostname',
render: (h: string | null) => h ?? '—', 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: 'Категория', title: 'Категория',
dataIndex: 'category', dataIndex: 'category',
@ -220,7 +313,7 @@ export function ObjectDetailPage() {
{ {
title: '', title: '',
key: 'actions', key: 'actions',
width: 120, width: 150,
render: (_: unknown, row: DeviceItem) => ( render: (_: unknown, row: DeviceItem) => (
<Space> <Space>
<Button <Button
@ -230,6 +323,11 @@ export function ObjectDetailPage() {
> >
Запустить Запустить
</Button> </Button>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => openEditDevice(row)}
/>
<Popconfirm <Popconfirm
title="Удалить устройство?" title="Удалить устройство?"
okText="Удалить" okText="Удалить"
@ -244,6 +342,88 @@ export function ObjectDetailPage() {
}, },
] ]
const zoneColumns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{
title: 'Описание',
dataIndex: 'description',
key: 'description',
render: (v: string | null) => v ?? '—',
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, row: ZoneItem) => (
<Space>
<Tooltip title="Задача на зону">
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => { setRackModal(false); openTaskModal({ type: 'zone', id: row.id }) }}
/>
</Tooltip>
<Popconfirm
title="Удалить зону?"
description="Стойки в этой зоне останутся, но привязка к зоне будет снята."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() =>
deleteZone.mutateAsync(row.id).catch(() => message.error('Ошибка удаления зоны'))
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
const rackColumns = [
{ title: 'Название', dataIndex: 'name', key: 'name' },
{
title: 'Зона',
dataIndex: 'zone_name',
key: 'zone_name',
render: (v: string | null) => v ? <Tag>{v}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Расположение',
dataIndex: 'location',
key: 'location',
render: (v: string | null) => v ?? '—',
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, row: RackItem) => (
<Space>
<Tooltip title="Задача на стойку">
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => { setRackModal(false); openTaskModal({ type: 'rack', id: row.id }) }}
/>
</Tooltip>
<Popconfirm
title="Удалить стойку?"
description="Устройства в стойке останутся, но привязка к стойке будет снята."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() =>
deleteRack.mutateAsync(row.id).catch(() => message.error('Ошибка удаления стойки'))
}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
// CSV preview columns // CSV preview columns
const previewCreateCols = [ const previewCreateCols = [
{ title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> }, { title: 'IP', dataIndex: 'ip', key: 'ip', render: (v: string) => <code>{v}</code> },
@ -290,9 +470,12 @@ export function ObjectDetailPage() {
<Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}> <Button icon={<UploadOutlined />} onClick={() => setCsvModal(true)}>
Импорт CSV Импорт CSV
</Button> </Button>
<Button icon={<PlusOutlined />} onClick={() => setDeviceModal(true)}> <Button icon={<PlusOutlined />} onClick={openAddDevice}>
Добавить устройство Добавить устройство
</Button> </Button>
<Button icon={<EditOutlined />} onClick={() => setRackModal(true)}>
Стойки и зоны
</Button>
<Button <Button
type="primary" type="primary"
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
@ -313,13 +496,93 @@ export function ObjectDetailPage() {
<Table <Table
dataSource={filteredDevices} dataSource={filteredDevices}
columns={columns} columns={deviceColumns}
rowKey="id" rowKey="id"
loading={devLoading} loading={devLoading}
size="middle" size="middle"
pagination={{ pageSize: 50 }} pagination={{ pageSize: 50 }}
/> />
{/* ── Rack & Zone Management Modal ── */}
<Modal
title="Стойки и зоны"
open={rackModal}
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }}
footer={null}
width={820}
>
{/* ── Zones ── */}
<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 />
{/* ── Racks ── */}
<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 ── */} {/* ── CSV Import Modal ── */}
<Modal <Modal
title="Импорт устройств из CSV" title="Импорт устройств из CSV"
@ -408,14 +671,14 @@ export function ObjectDetailPage() {
)} )}
</Modal> </Modal>
{/* ── Add Device Modal ── */} {/* ── Add / Edit Device Modal ── */}
<Modal <Modal
title="Добавить устройство" title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
open={deviceModal} open={deviceModal}
onCancel={() => { setDeviceModal(false); deviceForm.resetFields() }} onCancel={() => { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }}
onOk={handleAddDevice} onOk={handleSaveDevice}
confirmLoading={createDevice.isPending} confirmLoading={createDevice.isPending || updateDevice.isPending}
okText="Добавить" okText={editDevice ? 'Сохранить' : 'Добавить'}
width={600} width={600}
> >
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}> <Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
@ -464,11 +727,23 @@ export function ObjectDetailPage() {
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}>
<Form.Item name="rack_id" label="Стойка">
<Select
placeholder="Без стойки"
allowClear
options={rackOptions}
/>
</Form.Item>
</Col>
<Col span={12}> <Col span={12}>
<Form.Item name="model" label="Модель"> <Form.Item name="model" label="Модель">
<Input placeholder="DS-2CD2143G2-I" /> <Input placeholder="DS-2CD2143G2-I" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row>
<Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="mac" label="MAC"> <Form.Item name="mac" label="MAC">
<Input placeholder="AA:BB:CC:DD:EE:FF" /> <Input placeholder="AA:BB:CC:DD:EE:FF" />
@ -482,17 +757,17 @@ export function ObjectDetailPage() {
<Row gutter={16}> <Row gutter={16}>
<Col span={8}> <Col span={8}>
<Form.Item name="ssh_user" label="Логин"> <Form.Item name="ssh_user_override" label="Логин">
<Input placeholder="caps" /> <Input placeholder="caps" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={10}> <Col span={10}>
<Form.Item name="ssh_password" label="Пароль"> <Form.Item name="ssh_password_override" label="Пароль">
<Input.Password placeholder="••••••••" /> <Input.Password placeholder="••••••••" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Form.Item name="ssh_port" label="Порт"> <Form.Item name="ssh_port_override" label="Порт">
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" /> <InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="22" />
</Form.Item> </Form.Item>
</Col> </Col>

View file

@ -16,7 +16,7 @@ import {
Typography, Typography,
message, message,
} from 'antd' } from 'antd'
import { useEffect, useState } from 'react' import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { import {
useCreateObject, useCreateObject,
@ -25,8 +25,7 @@ import {
useObjects, useObjects,
type ObjectItem, type ObjectItem,
} from '../api/objects' } from '../api/objects'
import { stripEmpty } from '../utils/form'
type ModalMode = 'create' | 'edit'
export function ObjectsPage() { export function ObjectsPage() {
const navigate = useNavigate() const navigate = useNavigate()
@ -36,54 +35,50 @@ export function ObjectsPage() {
const deleteObject = useDeleteObject() const deleteObject = useDeleteObject()
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [modalMode, setModalMode] = useState<ModalMode>('create')
const [editTarget, setEditTarget] = useState<ObjectItem | null>(null) const [editTarget, setEditTarget] = useState<ObjectItem | null>(null)
const [searchText, setSearchText] = useState('')
const [form] = Form.useForm() const [form] = Form.useForm()
useEffect(() => { const filteredObjects = useMemo(() => {
if (modalMode === 'edit' && editTarget) { if (!searchText) return objects ?? []
form.setFieldsValue({ const q = searchText.toLowerCase()
name: editTarget.name, return (objects ?? []).filter((o) => o.name.toLowerCase().includes(q))
description: editTarget.description ?? '', }, [objects, searchText])
server_ip: editTarget.server_ip ?? '',
ssh_user: editTarget.ssh_user ?? '',
db_host: editTarget.db_host ?? '',
// passwords intentionally left blank — only sent if filled
})
}
}, [editTarget, modalMode, form])
const openCreate = () => { const openCreate = () => {
setModalMode('create')
setEditTarget(null)
form.resetFields() form.resetFields()
setEditTarget(null)
setModalOpen(true) setModalOpen(true)
} }
const openEdit = (row: ObjectItem) => { const openEdit = (row: ObjectItem) => {
setModalMode('edit')
setEditTarget(row)
form.resetFields() form.resetFields()
form.setFieldsValue({
name: row.name,
description: row.description ?? '',
server_ip: row.server_ip ?? '',
ssh_user: row.ssh_user ?? '',
db_host: row.db_host ?? '',
})
setEditTarget(row)
setModalOpen(true) setModalOpen(true)
} }
const handleSubmit = async () => { const handleSubmit = async () => {
const values = await form.validateFields() const values = await form.validateFields()
const payload = Object.fromEntries( const payload = stripEmpty(values)
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
)
try { try {
if (modalMode === 'create') { if (editTarget === null) {
await createObject.mutateAsync(payload as any) await createObject.mutateAsync(payload as any)
message.success('Объект создан') message.success('Объект создан')
} else { } else {
await updateObject.mutateAsync({ id: editTarget!.id, body: payload as any }) await updateObject.mutateAsync({ id: editTarget.id, body: payload as any })
message.success('Объект обновлён') message.success('Объект обновлён')
} }
setModalOpen(false) setModalOpen(false)
form.resetFields() form.resetFields()
} catch { } catch {
message.error(modalMode === 'create' ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта') message.error(editTarget === null ? 'Ошибка при создании объекта' : 'Ошибка при обновлении объекта')
} }
} }
@ -92,11 +87,10 @@ export function ObjectsPage() {
await deleteObject.mutateAsync(row.id) await deleteObject.mutateAsync(row.id)
message.success(`Объект "${row.name}" удалён`) message.success(`Объект "${row.name}" удалён`)
} catch (err: any) { } catch (err: any) {
const detail = err?.response?.data?.detail
if (err?.response?.status === 409) { if (err?.response?.status === 409) {
message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства') message.error('Нельзя удалить объект с устройствами — сначала удалите все устройства')
} else { } else {
message.error(detail ?? 'Ошибка при удалении объекта') message.error(err?.response?.data?.detail ?? 'Ошибка при удалении объекта')
} }
} }
} }
@ -194,13 +188,21 @@ export function ObjectsPage() {
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
Объекты Объекты
</Typography.Title> </Typography.Title>
<Space>
<Input.Search
placeholder="Поиск по названию..."
allowClear
style={{ width: 260 }}
onChange={(e) => setSearchText(e.target.value)}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Добавить Добавить
</Button> </Button>
</Space>
</div> </div>
<Table <Table
dataSource={objects ?? []} dataSource={filteredObjects}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
@ -209,12 +211,12 @@ export function ObjectsPage() {
/> />
<Modal <Modal
title={modalMode === 'create' ? 'Новый объект' : `Редактировать: ${editTarget?.name}`} title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
open={modalOpen} open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }} onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleSubmit} onOk={handleSubmit}
confirmLoading={isSubmitting} confirmLoading={isSubmitting}
okText={modalMode === 'create' ? 'Создать' : 'Сохранить'} okText={editTarget === null ? 'Создать' : 'Сохранить'}
width={640} width={640}
> >
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@ -244,8 +246,8 @@ export function ObjectsPage() {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={10}> <Col span={10}>
<Form.Item name="ssh_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}> <Form.Item name="ssh_password" label={editTarget ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} /> <Input.Password placeholder={editTarget ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={6}> <Col span={6}>
@ -287,8 +289,8 @@ export function ObjectsPage() {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={8}> <Col span={8}>
<Form.Item name="db_password" label={modalMode === 'edit' ? 'Новый пароль' : 'Пароль'}> <Form.Item name="db_password" label={editTarget ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={modalMode === 'edit' ? 'Оставьте пустым чтобы не менять' : '••••••••'} /> <Input.Password placeholder={editTarget ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>

View file

@ -29,14 +29,8 @@ export function TasksPage() {
}, },
{ {
title: 'Цель', title: 'Цель',
dataIndex: 'target_scope', key: 'target',
key: 'target_scope', render: (_: unknown, row: Task) => row.target_label ?? '—',
render: (s: Record<string, unknown>) => {
if (s.type === 'device') return `Устройство #${s.id}`
if (s.type === 'object') return `Объект #${s.id}`
if (s.type === 'category') return `Категория: ${s.value}`
return JSON.stringify(s)
},
}, },
{ {
title: 'Итог', title: 'Итог',

View file

@ -0,0 +1,5 @@
/** Strip empty strings, null, and undefined from form values before sending to API. */
export const stripEmpty = (values: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(
Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined)
)