111
This commit is contained in:
parent
86c02eb2e9
commit
e8fd5a2516
26 changed files with 1029 additions and 91 deletions
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(xargs grep:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/alembic/versions/0003_racks.py
Normal file
59
backend/alembic/versions/0003_racks.py
Normal 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")
|
||||||
58
backend/alembic/versions/0004_zones.py
Normal file
58
backend/alembic/versions/0004_zones.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
32
backend/app/models/rack.py
Normal file
32
backend/app/models/rack.py
Normal 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")
|
||||||
23
backend/app/models/zone.py
Normal file
23
backend/app/models/zone.py
Normal 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")
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
93
backend/app/routers/racks.py
Normal file
93
backend/app/routers/racks.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
83
backend/app/routers/zones.py
Normal file
83
backend/app/routers/zones.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
38
backend/app/schemas/rack.py
Normal file
38
backend/app/schemas/rack.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
24
backend/app/schemas/zone.py
Normal file
24
backend/app/schemas/zone.py
Normal 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}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
49
frontend/src/api/racks.ts
Normal 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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
38
frontend/src/api/zones.ts
Normal 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] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
await createDevice.mutateAsync(payload as any)
|
if (editDevice) {
|
||||||
message.success('Устройство добавлено')
|
await updateDevice.mutateAsync({ id: editDevice.id, body: payload as any })
|
||||||
|
message.success('Устройство обновлено')
|
||||||
|
} else {
|
||||||
|
await createDevice.mutateAsync(payload as any)
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
<Space>
|
||||||
Добавить
|
<Input.Search
|
||||||
</Button>
|
placeholder="Поиск по названию..."
|
||||||
|
allowClear
|
||||||
|
style={{ width: 260 }}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||||
|
Добавить
|
||||||
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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: 'Итог',
|
||||||
|
|
|
||||||
5
frontend/src/utils/form.ts
Normal file
5
frontend/src/utils/form.ts
Normal 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)
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue