diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cbd3cfd --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs grep:*)" + ] + } +} diff --git a/backend/alembic/versions/0003_racks.py b/backend/alembic/versions/0003_racks.py new file mode 100644 index 0000000..b283f2a --- /dev/null +++ b/backend/alembic/versions/0003_racks.py @@ -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") diff --git a/backend/alembic/versions/0004_zones.py b/backend/alembic/versions/0004_zones.py new file mode 100644 index 0000000..9f70f48 --- /dev/null +++ b/backend/alembic/versions/0004_zones.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 25b1a8c..4d68420 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 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 @@ -39,6 +39,8 @@ def create_app() -> FastAPI: app.include_router(auth.router) app.include_router(objects.router) app.include_router(devices.router) + app.include_router(racks.router) + app.include_router(zones.router) app.include_router(tags.router) app.include_router(tasks.router) app.include_router(events.router) diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 79a5c55..335c461 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -57,7 +57,15 @@ class Device(Base, TimestampMixin): last_ping_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) 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) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) object: Mapped["Object"] = relationship("Object", back_populates="devices") + rack: Mapped[Optional["Rack"]] = relationship("Rack", back_populates="devices") diff --git a/backend/app/models/object.py b/backend/app/models/object.py index d26dcdc..5001b16 100644 --- a/backend/app/models/object.py +++ b/backend/app/models/object.py @@ -46,4 +46,10 @@ class Object(Base, TimestampMixin): devices: Mapped[list["Device"]] = relationship( "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) diff --git a/backend/app/models/rack.py b/backend/app/models/rack.py new file mode 100644 index 0000000..d5f3971 --- /dev/null +++ b/backend/app/models/rack.py @@ -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") diff --git a/backend/app/models/zone.py b/backend/app/models/zone.py new file mode 100644 index 0000000..faef0b9 --- /dev/null +++ b/backend/app/models/zone.py @@ -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") diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index 218bae7..867e100 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -4,6 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, 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 @@ -52,7 +53,11 @@ async def list_devices( _: User = Depends(get_current_user), ): 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: query = query.where(Device.category == category) if role: @@ -63,7 +68,8 @@ async def list_devices( query = query.where(Device.is_active == is_active) query = query.order_by(Device.ip) 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) @@ -84,7 +90,11 @@ async def create_device( db.add(device) await db.flush() 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) @@ -199,7 +209,15 @@ async def get_device( db: AsyncSession = Depends(get_db), _: 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) diff --git a/backend/app/routers/racks.py b/backend/app/routers/racks.py new file mode 100644 index 0000000..e05853b --- /dev/null +++ b/backend/app/routers/racks.py @@ -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) diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index 4c0cdde..c0f5874 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -4,7 +4,11 @@ from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession 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.zone import Zone from app.models.user import User from app.plugins.registry import action_registry 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"]) +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]) async def list_actions(_: User = Depends(get_current_user)): return [ @@ -30,6 +102,7 @@ async def list_actions(_: User = Depends(get_current_user)): async def create_task( body: TaskCreate, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): try: task = await task_service.create_task( @@ -40,20 +113,30 @@ async def create_task( ) except ValueError as 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]) async def list_tasks( limit: int = 50, - offset: int = 0, + skip: int = 0, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - result = await db.execute( - select(Task).order_by(desc(Task.created_at)).offset(offset).limit(limit) + rows = await db.execute( + 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) @@ -62,12 +145,15 @@ async def get_task( db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - result = await db.execute( + rows = await db.execute( select(Task) .options(selectinload(Task.events), selectinload(Task.device_results)) .where(Task.id == task_id) ) - task = result.scalar_one_or_none() + task = rows.scalar_one_or_none() if task is None: 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 diff --git a/backend/app/routers/zones.py b/backend/app/routers/zones.py new file mode 100644 index 0000000..451a307 --- /dev/null +++ b/backend/app/routers/zones.py @@ -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) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index 9bb9e3a..cda854d 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -15,6 +15,7 @@ class DeviceCreate(BaseModel): model: Optional[str] = None firmware_version: Optional[str] = None source: str = "manual" + rack_id: Optional[int] = None ssh_user_override: Optional[str] = None ssh_password_override: Optional[str] = None # plaintext, encrypted on write ssh_port_override: Optional[int] = None @@ -46,6 +47,7 @@ class DeviceUpdate(BaseModel): role: Optional[str] = None model: Optional[str] = None firmware_version: Optional[str] = None + rack_id: Optional[int] = None ssh_user_override: Optional[str] = None ssh_password_override: Optional[str] = None ssh_port_override: Optional[int] = None @@ -67,6 +69,8 @@ class DeviceRead(BaseModel): firmware_version: Optional[str] = None source: str external_id: Optional[str] = None + rack_id: Optional[int] = None + rack_name: Optional[str] = None ssh_user_override: Optional[str] = None ssh_port_override: Optional[int] = None device_meta: dict[str, Any] = {} @@ -82,6 +86,13 @@ class DeviceRead(BaseModel): 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): ip: str diff --git a/backend/app/schemas/rack.py b/backend/app/schemas/rack.py new file mode 100644 index 0000000..81c9548 --- /dev/null +++ b/backend/app/schemas/rack.py @@ -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 diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 1fe3889..5d95870 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -38,6 +38,7 @@ class TaskRead(BaseModel): type: str status: str target_scope: dict + target_label: Optional[str] = None params: dict result: Optional[dict] = None error_message: Optional[str] = None diff --git a/backend/app/schemas/zone.py b/backend/app/schemas/zone.py new file mode 100644 index 0000000..57073fa --- /dev/null +++ b/backend/app/schemas/zone.py @@ -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} diff --git a/backend/app/services/object_db.py b/backend/app/services/object_db.py index 14d7205..a7250c5 100644 --- a/backend/app/services/object_db.py +++ b/backend/app/services/object_db.py @@ -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"user={obj.db_user} password={db_password} connect_timeout=10" ) - try: - remote_rows = await _fetch_rows(dsn, obj.db_table) - finally: - listener.close() + remote_rows = await _fetch_rows(dsn, obj.db_table) except Exception as exc: result.errors.append(f"SSH tunnel failed: {exc}") return result diff --git a/backend/app/services/task.py b/backend/app/services/task.py index 1517afe..4fe02f1 100644 --- a/backend/app/services/task.py +++ b/backend/app/services/task.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import selectinload from app.models.device import Device from app.models.object import Object +from app.models.rack import Rack from app.models.task import Task, TaskEvent, TaskResult from app.models.user import User 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"]) elif scope_type == "object": 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": 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": query = query.where(Device.role == scope["value"]) if "object_id" in scope: diff --git a/frontend/src/api/objects.ts b/frontend/src/api/objects.ts index 3e2d36c..aec2a4a 100644 --- a/frontend/src/api/objects.ts +++ b/frontend/src/api/objects.ts @@ -23,6 +23,10 @@ export interface DeviceItem { role: string model: string | null source: string + rack_id: number | null + rack_name: string | null + ssh_user_override: string | null + ssh_port_override: number | null status: string last_seen: string | null last_ping_ms: number | null @@ -124,9 +128,10 @@ export interface DeviceCreatePayload { category: string role: string model?: string - ssh_user?: string - ssh_password?: string - ssh_port?: number + rack_id?: number | null + ssh_user_override?: string + ssh_password_override?: string + ssh_port_override?: number 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 }) => + api.patch(`/api/v1/objects/${objId}/devices/${id}`, body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['devices', objId] }), + }) +} + export const useDeleteDevice = (objId: number) => { const qc = useQueryClient() return useMutation({ diff --git a/frontend/src/api/racks.ts b/frontend/src/api/racks.ts new file mode 100644 index 0000000..9538ba7 --- /dev/null +++ b/frontend/src/api/racks.ts @@ -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(`/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(`/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(`/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] }) + }, + }) +} diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index eb65bd1..035f86b 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -24,6 +24,7 @@ export interface Task { type: string status: string target_scope: Record + target_label: string | null params: Record result: { total: number; ok: number; failed: number } | null error_message: string | null diff --git a/frontend/src/api/zones.ts b/frontend/src/api/zones.ts new file mode 100644 index 0000000..f7f761d --- /dev/null +++ b/frontend/src/api/zones.ts @@ -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(`/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(`/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] }) + }, + }) +} diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx index d6c37d6..066ae08 100644 --- a/frontend/src/pages/ObjectDetail.tsx +++ b/frontend/src/pages/ObjectDetail.tsx @@ -1,5 +1,6 @@ import { DeleteOutlined, + EditOutlined, PlayCircleOutlined, PlusOutlined, SyncOutlined, @@ -30,11 +31,12 @@ import { message, } from 'antd' import type { UploadFile } from 'antd' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useCreateDevice, useDeleteDevice, + useUpdateDevice, useDevices, useImportCSV, useObject, @@ -43,8 +45,11 @@ import { 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 { useActions, useCreateTask } from '../api/tasks' import { CategoryTag, DeviceStatusBadge } from '../components/StatusBadge' +import { stripEmpty } from '../utils/form' import dayjs from 'dayjs' export function ObjectDetailPage() { @@ -54,27 +59,43 @@ export function ObjectDetailPage() { 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 { data: actions } = useActions() const createTask = useCreateTask() 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 [taskModal, setTaskModal] = useState<{ scope: object } | null>(null) const [deviceModal, setDeviceModal] = useState(false) + const [editDevice, setEditDevice] = useState(null) const [csvModal, setCsvModal] = useState(false) + const [rackModal, setRackModal] = useState(false) const [csvFile, setCsvFile] = useState(null) const [csvPreview, setCsvPreview] = useState(null) const [searchText, setSearchText] = useState('') const [form] = Form.useForm() const [deviceForm] = Form.useForm() + const [rackForm] = Form.useForm() + const [zoneForm] = Form.useForm() const [selectedAction, setSelectedAction] = useState('') 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) => { setTaskModal({ scope }) 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 payload = Object.fromEntries( - Object.entries(values).filter(([, v]) => v !== '' && v !== null && v !== undefined) - ) + const payload = stripEmpty(values) + // rack_id=null means "detach rack" — allow explicit null + if ('rack_id' in values && values.rack_id === undefined) { + payload.rack_id = null + } try { - await createDevice.mutateAsync(payload as any) - message.success('Устройство добавлено') + 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('Ошибка при добавлении устройства') + message.error(editDevice ? 'Ошибка при обновлении устройства' : 'Ошибка при добавлении устройства') } } @@ -160,15 +212,41 @@ export function ObjectDetailPage() { setCsvPreview(null) } - const filteredDevices = (devices ?? []).filter((d) => { - if (!searchText) return true + const handleAddZone = async () => { + 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() - 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 - const columns = [ + const deviceColumns = [ { title: 'IP', dataIndex: 'ip', @@ -181,6 +259,21 @@ export function ObjectDetailPage() { key: 'hostname', render: (h: string | null) => h ?? '—', }, + { + title: 'Стойка', + dataIndex: 'rack_name', + key: 'rack_name', + render: (name: string | null) => + name ? {name} : , + 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', @@ -220,7 +313,7 @@ export function ObjectDetailPage() { { title: '', key: 'actions', - width: 120, + width: 150, render: (_: unknown, row: DeviceItem) => ( + - +