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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware
from app.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)

View file

@ -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")

View file

@ -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)

View file

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

View file

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

View file

@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from 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)

View file

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

View file

@ -4,7 +4,11 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from 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

View file

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

View file

@ -15,6 +15,7 @@ class DeviceCreate(BaseModel):
model: Optional[str] = None
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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:

View file

@ -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<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) => {
const qc = useQueryClient()
return useMutation({

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

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

View file

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

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

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

View file

@ -1,5 +1,6 @@
import {
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<DeviceItem | null>(null)
const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false)
const [csvFile, setCsvFile] = useState<File | null>(null)
const [csvPreview, setCsvPreview] = useState<DeviceImportPreview | null>(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<string>('')
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 {
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 <Spin />
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 ? <Tag color="blue">{name}</Tag> : <Typography.Text type="secondary"></Typography.Text>,
filters: [
{ text: 'Без стойки', value: '__none__' },
...(racks ?? []).map((r) => ({ text: r.name, value: r.name })),
],
onFilter: (value: unknown, record: DeviceItem) => {
if (value === '__none__') return record.rack_name === null
return record.rack_name === value
},
},
{
title: 'Категория',
dataIndex: 'category',
@ -220,7 +313,7 @@ export function ObjectDetailPage() {
{
title: '',
key: 'actions',
width: 120,
width: 150,
render: (_: unknown, row: DeviceItem) => (
<Space>
<Button
@ -230,6 +323,11 @@ export function ObjectDetailPage() {
>
Запустить
</Button>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => openEditDevice(row)}
/>
<Popconfirm
title="Удалить устройство?"
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
const previewCreateCols = [
{ 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)}>
Импорт CSV
</Button>
<Button icon={<PlusOutlined />} onClick={() => setDeviceModal(true)}>
<Button icon={<PlusOutlined />} onClick={openAddDevice}>
Добавить устройство
</Button>
<Button icon={<EditOutlined />} onClick={() => setRackModal(true)}>
Стойки и зоны
</Button>
<Button
type="primary"
icon={<ThunderboltOutlined />}
@ -313,13 +496,93 @@ export function ObjectDetailPage() {
<Table
dataSource={filteredDevices}
columns={columns}
columns={deviceColumns}
rowKey="id"
loading={devLoading}
size="middle"
pagination={{ pageSize: 50 }}
/>
{/* ── Rack & Zone Management Modal ── */}
<Modal
title="Стойки и зоны"
open={rackModal}
onCancel={() => { setRackModal(false); rackForm.resetFields(); zoneForm.resetFields() }}
footer={null}
width={820}
>
{/* ── 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 ── */}
<Modal
title="Импорт устройств из CSV"
@ -408,14 +671,14 @@ export function ObjectDetailPage() {
)}
</Modal>
{/* ── Add Device Modal ── */}
{/* ── Add / Edit Device Modal ── */}
<Modal
title="Добавить устройство"
title={editDevice ? `Редактировать: ${editDevice.hostname ?? editDevice.ip}` : 'Добавить устройство'}
open={deviceModal}
onCancel={() => { setDeviceModal(false); deviceForm.resetFields() }}
onOk={handleAddDevice}
confirmLoading={createDevice.isPending}
okText="Добавить"
onCancel={() => { setDeviceModal(false); setEditDevice(null); deviceForm.resetFields() }}
onOk={handleSaveDevice}
confirmLoading={createDevice.isPending || updateDevice.isPending}
okText={editDevice ? 'Сохранить' : 'Добавить'}
width={600}
>
<Form form={deviceForm} layout="vertical" style={{ marginTop: 16 }}>
@ -464,11 +727,23 @@ export function ObjectDetailPage() {
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="rack_id" label="Стойка">
<Select
placeholder="Без стойки"
allowClear
options={rackOptions}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="model" label="Модель">
<Input placeholder="DS-2CD2143G2-I" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="mac" label="MAC">
<Input placeholder="AA:BB:CC:DD:EE:FF" />
@ -482,17 +757,17 @@ export function ObjectDetailPage() {
<Row gutter={16}>
<Col span={8}>
<Form.Item name="ssh_user" label="Логин">
<Form.Item name="ssh_user_override" label="Логин">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password" label="Пароль">
<Form.Item name="ssh_password_override" label="Пароль">
<Input.Password placeholder="••••••••" />
</Form.Item>
</Col>
<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" />
</Form.Item>
</Col>

View file

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

View file

@ -29,14 +29,8 @@ export function TasksPage() {
},
{
title: 'Цель',
dataIndex: 'target_scope',
key: 'target_scope',
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)
},
key: 'target',
render: (_: unknown, row: Task) => row.target_label ?? '—',
},
{
title: 'Итог',

View file

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