# Network Diagrams Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a standalone network diagram builder with drag-and-drop device nodes, connection edges, database-driven device types, AI generation (replace + merge), and PNG/PDF/JSON export. **Architecture:** Two new database tables (`device_types`, `network_diagrams`), two new FastAPI routers, an AI generation service using the existing `AnthropicProvider`, and a React Flow-based editor with toolbar, properties panel, and AI assist panel. Device types are database-driven with system defaults seeded on migration. Frontend rendering maps device type slugs to Lucide icons via a registry. **Tech Stack:** FastAPI, SQLAlchemy 2.0, Alembic, React 19, @xyflow/react v12, Tailwind v4, Lucide React, Anthropic Claude API **Design Spec:** `docs/superpowers/specs/2026-04-04-network-diagrams-design.md` --- ## File Map ### New Backend Files | File | Responsibility | |------|---------------| | `backend/alembic/versions/073_add_device_types_table.py` | Migration: `device_types` table + seed system defaults | | `backend/alembic/versions/074_add_network_diagrams_table.py` | Migration: `network_diagrams` table + indexes | | `backend/app/models/device_type.py` | SQLAlchemy model for `device_types` | | `backend/app/models/network_diagram.py` | SQLAlchemy model for `network_diagrams` | | `backend/app/schemas/device_type.py` | Pydantic schemas for device type CRUD | | `backend/app/schemas/network_diagram.py` | Pydantic schemas for diagrams + AI generation | | `backend/app/api/endpoints/device_types.py` | Device types router (CRUD) | | `backend/app/api/endpoints/network_diagrams.py` | Network diagrams router (CRUD + AI + export/import) | | `backend/app/services/network_diagram_ai_service.py` | AI generation logic (system prompt, parsing, merge) | ### Modified Backend Files | File | Change | |------|--------| | `backend/app/models/__init__.py` | Add `DeviceType`, `NetworkDiagram` imports | | `backend/app/api/router.py` | Register `device_types` and `network_diagrams` routers | | `backend/app/core/config.py` | Add `network_diagram_generate` to `ACTION_MODEL_MAP` | ### New Frontend Files | File | Responsibility | |------|---------------| | `frontend/src/types/network-diagram.ts` | TypeScript interfaces for all network diagram types | | `frontend/src/api/deviceTypes.ts` | API client for device types endpoints | | `frontend/src/api/networkDiagrams.ts` | API client for network diagrams endpoints | | `frontend/src/components/network/nodes/deviceRegistry.ts` | Slug → { icon, color } mapping + category fallbacks | | `frontend/src/components/network/nodes/DeviceNode.tsx` | React Flow custom node component | | `frontend/src/components/network/nodes/nodeTypes.ts` | Node type registry for React Flow | | `frontend/src/components/network/edges/ConnectionEdge.tsx` | Custom edge with connection-type styling | | `frontend/src/components/network/edges/edgeTypes.ts` | Edge type registry for React Flow | | `frontend/src/components/network/panels/DeviceToolbar.tsx` | Left panel — categorized, searchable, draggable | | `frontend/src/components/network/panels/PropertiesPanel.tsx` | Right panel — node/edge property editor | | `frontend/src/components/network/panels/AIAssistPanel.tsx` | Bottom collapsible — AI generation | | `frontend/src/components/network/NetworkCanvas.tsx` | React Flow wrapper | | `frontend/src/components/network/DiagramHeader.tsx` | Top bar — name, save, export | | `frontend/src/pages/NetworkDiagrams/index.tsx` | List/dashboard page | | `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` | Full editor page | ### Modified Frontend Files | File | Change | |------|--------| | `frontend/src/types/index.ts` | Export `network-diagram` types | | `frontend/src/api/index.ts` | Export `deviceTypesApi` and `networkDiagramsApi` | | `frontend/src/components/layout/Sidebar.tsx` | Add "Network Maps" nav item | | `frontend/src/router.tsx` | Add 3 routes for network diagrams | --- ## Task 1: Device Types Migration + Model **Files:** - Create: `backend/alembic/versions/073_add_device_types_table.py` - Create: `backend/app/models/device_type.py` - Modify: `backend/app/models/__init__.py` - [ ] **Step 1: Create the device_type SQLAlchemy model** Create `backend/app/models/device_type.py`: ```python """Device type model for network diagrams.""" import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.core.database import Base if TYPE_CHECKING: pass class DeviceType(Base): """A device type for network diagram nodes (system or team-custom).""" __tablename__ = "device_types" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) slug: Mapped[str] = mapped_column( String(50), nullable=False, comment="Unique identifier used in diagram node data", ) label: Mapped[str] = mapped_column( String(100), nullable=False, comment="Display name", ) category: Mapped[str] = mapped_column( String(50), nullable=False, comment="network, compute, storage, cloud, endpoint, infrastructure, security", ) is_system: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, comment="True for built-in types that cannot be deleted", ) team_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=True, comment="NULL for system types, set for team-custom types", ) sort_order: Mapped[int] = mapped_column( Integer, nullable=False, default=0, comment="Display order within category", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) __table_args__ = ( # PostgreSQL 15+ NULLS NOT DISTINCT so system types (team_id=NULL) are unique by slug # Implemented in migration as raw SQL since SQLAlchemy doesn't support this modifier ) ``` - [ ] **Step 2: Create the Alembic migration** Create `backend/alembic/versions/073_add_device_types_table.py`: ```python """Add device_types table with system seed data. Revision ID: 073 Revises: 072 Create Date: 2026-04-04 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID import uuid revision = "073" down_revision = "072" branch_labels = None depends_on = None # System device types to seed SYSTEM_DEVICE_TYPES = [ # Network ("router", "Router", "network", 0), ("switch", "Switch", "network", 1), ("firewall", "Firewall", "network", 2), ("access-point", "Access Point", "network", 3), ("load-balancer", "Load Balancer", "network", 4), # Compute ("server", "Server", "compute", 0), ("workstation", "Workstation", "compute", 1), ("vm", "Virtual Machine", "compute", 2), ("container", "Container", "compute", 3), # Storage ("nas", "NAS", "storage", 0), ("san", "SAN", "storage", 1), ("cloud-storage", "Cloud Storage", "storage", 2), # Cloud ("cloud", "Cloud", "cloud", 0), ("aws", "AWS", "cloud", 1), ("azure", "Azure", "cloud", 2), ("gcp", "Google Cloud", "cloud", 3), # Endpoint ("printer", "Printer", "endpoint", 0), ("phone", "Phone", "endpoint", 1), ("iot", "IoT Device", "endpoint", 2), ("camera", "Camera", "endpoint", 3), ("tablet", "Tablet", "endpoint", 4), ("laptop", "Laptop", "endpoint", 5), # Infrastructure ("ups", "UPS", "infrastructure", 0), ("pdu", "PDU", "infrastructure", 1), ("rack", "Rack", "infrastructure", 2), ("patch-panel", "Patch Panel", "infrastructure", 3), # Security ("nvr", "NVR", "security", 0), ("badge-reader", "Badge Reader", "security", 1), ] def upgrade() -> None: op.create_table( "device_types", sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), sa.Column("slug", sa.String(50), nullable=False), sa.Column("label", sa.String(100), nullable=False), sa.Column("category", sa.String(50), nullable=False), sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True), sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), ) # NULLS NOT DISTINCT so (slug, NULL) is unique for system types op.execute( "ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team " "UNIQUE NULLS NOT DISTINCT (slug, team_id)" ) op.create_index("idx_device_types_team", "device_types", ["team_id"]) # Seed system device types device_types_table = sa.table( "device_types", sa.column("id", UUID(as_uuid=True)), sa.column("slug", sa.String), sa.column("label", sa.String), sa.column("category", sa.String), sa.column("is_system", sa.Boolean), sa.column("team_id", UUID(as_uuid=True)), sa.column("sort_order", sa.Integer), ) op.bulk_insert(device_types_table, [ { "id": uuid.uuid4(), "slug": slug, "label": label, "category": category, "is_system": True, "team_id": None, "sort_order": sort_order, } for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES ]) def downgrade() -> None: op.drop_table("device_types") ``` - [ ] **Step 3: Register model in `__init__.py`** Add to `backend/app/models/__init__.py`: Import line (add after the last import): ```python from .device_type import DeviceType ``` Add `"DeviceType"` to the `__all__` list. - [ ] **Step 4: Verify migration applies cleanly** Run: ```bash cd backend && source venv/bin/activate && alembic upgrade head ``` Expected: Migration 073 applies without errors. - [ ] **Step 5: Verify seed data** Run: ```bash docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SELECT count(*) FROM device_types WHERE is_system = true;" ``` Expected: `28` (the number of system device types). - [ ] **Step 6: Commit** ```bash git add backend/alembic/versions/073_add_device_types_table.py backend/app/models/device_type.py backend/app/models/__init__.py git commit -m "feat: add device_types table with system seed data" ``` --- ## Task 2: Network Diagrams Migration + Model **Files:** - Create: `backend/alembic/versions/074_add_network_diagrams_table.py` - Create: `backend/app/models/network_diagram.py` - Modify: `backend/app/models/__init__.py` - [ ] **Step 1: Create the network_diagram SQLAlchemy model** Create `backend/app/models/network_diagram.py`: ```python """Network diagram model.""" import uuid from datetime import datetime, timezone from typing import Optional, TYPE_CHECKING from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.dialects.postgresql import UUID, JSONB from app.core.database import Base if TYPE_CHECKING: from app.models.user import User class NetworkDiagram(Base): """A network topology diagram, team-scoped.""" __tablename__ = "network_diagrams" id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 ) team_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=False, index=True, ) name: Mapped[str] = mapped_column(String(255), nullable=False) client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) asset_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) nodes: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'") edges: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'") thumbnail_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True) is_archived: Mapped[bool] = mapped_column( Boolean, nullable=False, default=False, ) created_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) # Relationships creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by]) ``` - [ ] **Step 2: Create the Alembic migration** Create `backend/alembic/versions/074_add_network_diagrams_table.py`: ```python """Add network_diagrams table. Revision ID: 074 Revises: 073 Create Date: 2026-04-04 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID, JSONB revision = "074" down_revision = "073" branch_labels = None depends_on = None def upgrade() -> None: op.create_table( "network_diagrams", sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=False), sa.Column("name", sa.String(255), nullable=False), sa.Column("client_name", sa.String(255), nullable=True), sa.Column("asset_name", sa.String(255), nullable=True), sa.Column("description", sa.Text(), nullable=True), sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")), sa.Column("thumbnail_url", sa.Text(), nullable=True), sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")), sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True), sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), ) op.create_index("idx_network_diagrams_team", "network_diagrams", ["team_id"]) op.create_index("idx_network_diagrams_client", "network_diagrams", ["team_id", "client_name"]) def downgrade() -> None: op.drop_table("network_diagrams") ``` - [ ] **Step 3: Register model in `__init__.py`** Add to `backend/app/models/__init__.py`: Import line: ```python from .network_diagram import NetworkDiagram ``` Add `"NetworkDiagram"` to the `__all__` list. - [ ] **Step 4: Verify migration applies cleanly** Run: ```bash cd backend && alembic upgrade head ``` Expected: Migration 074 applies without errors. - [ ] **Step 5: Commit** ```bash git add backend/alembic/versions/074_add_network_diagrams_table.py backend/app/models/network_diagram.py backend/app/models/__init__.py git commit -m "feat: add network_diagrams table" ``` --- ## Task 3: Backend Pydantic Schemas **Files:** - Create: `backend/app/schemas/device_type.py` - Create: `backend/app/schemas/network_diagram.py` - [ ] **Step 1: Create device type schemas** Create `backend/app/schemas/device_type.py`: ```python """Pydantic schemas for device types.""" from datetime import datetime from typing import Optional from uuid import UUID from pydantic import BaseModel, Field class DeviceTypeCreate(BaseModel): """Request to create a custom device type.""" slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$") label: str = Field(min_length=1, max_length=100) category: str = Field( min_length=1, max_length=50, pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$", ) sort_order: int = Field(default=0, ge=0) class DeviceTypeUpdate(BaseModel): """Request to update a custom device type.""" label: str | None = Field(default=None, min_length=1, max_length=100) category: str | None = Field( default=None, min_length=1, max_length=50, pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$", ) sort_order: int | None = Field(default=None, ge=0) class DeviceTypeResponse(BaseModel): """Device type as returned from the API.""" id: UUID slug: str label: str category: str is_system: bool team_id: UUID | None = None sort_order: int created_at: datetime model_config = {"from_attributes": True} ``` - [ ] **Step 2: Create network diagram schemas** Create `backend/app/schemas/network_diagram.py`: ```python """Pydantic schemas for network diagrams.""" from datetime import datetime from typing import Optional from uuid import UUID from pydantic import BaseModel, Field class DeviceProperties(BaseModel): """Properties of a device node.""" hostname: str | None = None ip: str | None = None subnet: str | None = None vendor: str | None = None model: str | None = None role: str | None = None vlan: str | None = None notes: str | None = None status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$") class DiagramNode(BaseModel): """A node in a network diagram.""" id: str type: str label: str position: dict # { "x": number, "y": number } properties: DeviceProperties = Field(default_factory=DeviceProperties) class DiagramEdge(BaseModel): """An edge (connection) in a network diagram.""" id: str source: str target: str label: str | None = None connectionType: str = "ethernet" speed: str | None = None notes: str | None = None class NetworkDiagramCreate(BaseModel): """Request to create a new diagram.""" name: str = Field(min_length=1, max_length=255) client_name: str | None = None asset_name: str | None = None description: str | None = None nodes: list[DiagramNode] = Field(default_factory=list) edges: list[DiagramEdge] = Field(default_factory=list) class NetworkDiagramUpdate(BaseModel): """Request to update a diagram (all fields optional).""" name: str | None = Field(default=None, min_length=1, max_length=255) client_name: str | None = None asset_name: str | None = None description: str | None = None nodes: list[DiagramNode] | None = None edges: list[DiagramEdge] | None = None class NetworkDiagramResponse(BaseModel): """Full diagram as returned from the API.""" id: UUID team_id: UUID name: str client_name: str | None = None asset_name: str | None = None description: str | None = None nodes: list[DiagramNode] = Field(default_factory=list) edges: list[DiagramEdge] = Field(default_factory=list) thumbnail_url: str | None = None is_archived: bool = False created_by: UUID | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class NetworkDiagramListItem(BaseModel): """Lightweight diagram for list views.""" id: UUID name: str client_name: str | None = None description: str | None = None node_count: int = 0 created_by: UUID | None = None created_at: datetime updated_at: datetime model_config = {"from_attributes": True} class ExistingBounds(BaseModel): """Bounding box of existing nodes for merge mode.""" minX: float maxX: float minY: float maxY: float class AIGenerateRequest(BaseModel): """Request for AI diagram generation.""" description: str = Field(min_length=1, max_length=5000) client_name: str | None = None mode: str = Field(default="replace", pattern=r"^(replace|merge)$") existingBounds: ExistingBounds | None = None class AIGenerateResponse(BaseModel): """AI-generated diagram data.""" nodes: list[DiagramNode] edges: list[DiagramEdge] suggestedName: str | None = None notes: str | None = None class DiagramImportRequest(BaseModel): """Imported JSON diagram data.""" schemaVersion: int = Field(ge=1, le=1) name: str = Field(min_length=1, max_length=255) client_name: str | None = None description: str | None = None nodes: list[DiagramNode] = Field(default_factory=list) edges: list[DiagramEdge] = Field(default_factory=list) class DiagramImportResponse(BaseModel): """Response after importing a diagram.""" diagram: NetworkDiagramResponse warnings: list[str] = Field(default_factory=list) class DiagramExportResponse(BaseModel): """JSON export format.""" schemaVersion: int = 1 name: str client_name: str | None = None description: str | None = None nodes: list[DiagramNode] edges: list[DiagramEdge] exportedAt: str ``` - [ ] **Step 3: Commit** ```bash git add backend/app/schemas/device_type.py backend/app/schemas/network_diagram.py git commit -m "feat: add Pydantic schemas for device types and network diagrams" ``` --- ## Task 4: Device Types Router **Files:** - Create: `backend/app/api/endpoints/device_types.py` - Modify: `backend/app/api/router.py` - [ ] **Step 1: Create the device types router** Create `backend/app/api/endpoints/device_types.py`: ```python """Device types API endpoints.""" from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, or_ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.api.deps import get_current_active_user from app.models.user import User from app.models.device_type import DeviceType from app.schemas.device_type import ( DeviceTypeCreate, DeviceTypeUpdate, DeviceTypeResponse, ) router = APIRouter(prefix="/device-types", tags=["device-types"]) @router.get("/", response_model=list[DeviceTypeResponse]) async def list_device_types( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[DeviceTypeResponse]: """List all device types: system defaults + team custom.""" stmt = ( select(DeviceType) .where( or_( DeviceType.is_system.is_(True), DeviceType.team_id == current_user.team_id, ) ) .order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label) ) result = await db.execute(stmt) rows = result.scalars().all() return [DeviceTypeResponse.model_validate(r) for r in rows] @router.post("/", response_model=DeviceTypeResponse, status_code=201) async def create_device_type( data: DeviceTypeCreate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> DeviceTypeResponse: """Create a custom device type for the team.""" # Check slug uniqueness within the team existing = await db.execute( select(DeviceType).where( DeviceType.slug == data.slug, DeviceType.team_id == current_user.team_id, ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your team") # Also check if slug conflicts with a system type system_existing = await db.execute( select(DeviceType).where( DeviceType.slug == data.slug, DeviceType.is_system.is_(True), ) ) if system_existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type") device_type = DeviceType( slug=data.slug, label=data.label, category=data.category, is_system=False, team_id=current_user.team_id, sort_order=data.sort_order, ) db.add(device_type) await db.commit() await db.refresh(device_type) return DeviceTypeResponse.model_validate(device_type) @router.put("/{device_type_id}", response_model=DeviceTypeResponse) async def update_device_type( device_type_id: UUID, data: DeviceTypeUpdate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> DeviceTypeResponse: """Update a custom device type (team-owned only).""" device_type = await db.get(DeviceType, device_type_id) if not device_type: raise HTTPException(status_code=404, detail="Device type not found") if device_type.is_system: raise HTTPException(status_code=403, detail="Cannot modify system device types") if device_type.team_id != current_user.team_id: raise HTTPException(status_code=404, detail="Device type not found") update_data = data.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(device_type, field, value) await db.commit() await db.refresh(device_type) return DeviceTypeResponse.model_validate(device_type) @router.delete("/{device_type_id}", status_code=204) async def delete_device_type( device_type_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> None: """Delete a custom device type (team-owned only).""" device_type = await db.get(DeviceType, device_type_id) if not device_type: raise HTTPException(status_code=404, detail="Device type not found") if device_type.is_system: raise HTTPException(status_code=403, detail="Cannot delete system device types") if device_type.team_id != current_user.team_id: raise HTTPException(status_code=404, detail="Device type not found") await db.delete(device_type) await db.commit() ``` - [ ] **Step 2: Register in router.py** Add to `backend/app/api/router.py`: Import line: ```python from app.api.endpoints import device_types ``` Registration line (add after the last `include_router` call): ```python api_router.include_router(device_types.router) ``` - [ ] **Step 3: Verify the endpoint starts** Run: ```bash cd backend && uvicorn app.main:app --reload & sleep 3 curl -s http://localhost:8000/api/docs | head -5 kill %1 ``` Expected: OpenAPI docs load, `/api/v1/device-types/` appears in the spec. - [ ] **Step 4: Commit** ```bash git add backend/app/api/endpoints/device_types.py backend/app/api/router.py git commit -m "feat: add device types CRUD router" ``` --- ## Task 5: Network Diagrams AI Service **Files:** - Create: `backend/app/services/network_diagram_ai_service.py` - Modify: `backend/app/core/config.py` - [ ] **Step 1: Add action to model tier routing** In `backend/app/core/config.py`, add to the `ACTION_MODEL_MAP` dict: ```python "network_diagram_generate": "standard", ``` - [ ] **Step 2: Create the AI generation service** Create `backend/app/services/network_diagram_ai_service.py`: ```python """AI service for generating network diagrams from natural language.""" import json import logging from app.core.ai_provider import get_ai_provider from app.core.config import settings from app.schemas.network_diagram import ( AIGenerateRequest, AIGenerateResponse, DiagramNode, DiagramEdge, DeviceProperties, ) logger = logging.getLogger(__name__) SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers. Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble. Return this exact structure: {{ "nodes": [ {{ "id": "unique-string", "type": "device-type-slug", "label": "device label", "position": {{ "x": number, "y": number }}, "properties": {{ "hostname": "string or null", "ip": "string or null", "subnet": "string or null", "vendor": "string or null", "model": "string or null", "role": "string or null", "vlan": "string or null", "notes": "string or null", "status": "unknown" }} }} ], "edges": [ {{ "id": "unique-string", "source": "node-id", "target": "node-id", "label": "connection label or null", "connectionType": "ethernet|fiber|wifi|vpn|vlan|wan", "speed": "string or null", "notes": "string or null" }} ], "suggestedName": "short descriptive diagram name", "notes": "any important assumptions or missing info, or null" }} Available device type slugs: {available_slugs} Position nodes thoughtfully in a logical network topology layout. Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y. Place WAN/internet at top, core network in middle, endpoints at bottom. {merge_instructions}""" MERGE_INSTRUCTIONS = """ IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices. The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}. Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100). You may create edges that connect new nodes to existing nodes if the description implies a connection. Use these existing node IDs for connections: {existing_node_ids}""" async def generate_diagram( request: AIGenerateRequest, available_slugs: list[str], existing_node_ids: list[str] | None = None, ) -> AIGenerateResponse: """Generate a network diagram from a natural language description. Args: request: The generation request with description and mode. available_slugs: List of valid device type slugs. existing_node_ids: For merge mode, IDs of existing nodes on canvas. Returns: AIGenerateResponse with nodes, edges, suggestedName, notes. Raises: ValueError: If the AI response cannot be parsed as valid JSON. """ merge_instructions = "" if request.mode == "merge" and request.existingBounds: b = request.existingBounds merge_instructions = MERGE_INSTRUCTIONS.format( minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY, existing_node_ids=", ".join(existing_node_ids or []), ) system_prompt = SYSTEM_PROMPT_TEMPLATE.format( available_slugs=", ".join(available_slugs), merge_instructions=merge_instructions, ) model = settings.get_model_for_action("network_diagram_generate") provider = get_ai_provider(model) messages = [{"role": "user", "content": request.description}] response_text, input_tokens, output_tokens = await provider.generate_json( system_prompt=system_prompt, messages=messages, max_tokens=4096, ) logger.info( "Network diagram AI generation: input_tokens=%d, output_tokens=%d", input_tokens, output_tokens, ) # Parse the JSON response try: data = json.loads(response_text) except json.JSONDecodeError as e: logger.error("Failed to parse AI response as JSON: %s", e) raise ValueError("AI generated an invalid response, please try again") # Validate and build nodes nodes = [] for raw_node in data.get("nodes", []): # Fall back to a generic type if the slug isn't in the available list node_type = raw_node.get("type", "server") if node_type not in available_slugs: logger.warning("Unknown device type '%s', falling back to 'server'", node_type) node_type = "server" nodes.append(DiagramNode( id=raw_node["id"], type=node_type, label=raw_node.get("label", node_type), position=raw_node.get("position", {"x": 0, "y": 0}), properties=DeviceProperties(**{ k: v for k, v in raw_node.get("properties", {}).items() if k in DeviceProperties.model_fields }), )) # Build edges edges = [] for raw_edge in data.get("edges", []): edges.append(DiagramEdge( id=raw_edge["id"], source=raw_edge["source"], target=raw_edge["target"], label=raw_edge.get("label"), connectionType=raw_edge.get("connectionType", "ethernet"), speed=raw_edge.get("speed"), notes=raw_edge.get("notes"), )) return AIGenerateResponse( nodes=nodes, edges=edges, suggestedName=data.get("suggestedName"), notes=data.get("notes"), ) ``` - [ ] **Step 3: Commit** ```bash git add backend/app/services/network_diagram_ai_service.py backend/app/core/config.py git commit -m "feat: add AI generation service for network diagrams" ``` --- ## Task 6: Network Diagrams Router **Files:** - Create: `backend/app/api/endpoints/network_diagrams.py` - Modify: `backend/app/api/router.py` - [ ] **Step 1: Create the network diagrams router** Create `backend/app/api/endpoints/network_diagrams.py`: ```python """Network diagrams API endpoints.""" from datetime import datetime, timezone from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.api.deps import get_current_active_user from app.models.user import User from app.models.device_type import DeviceType from app.models.network_diagram import NetworkDiagram from app.schemas.network_diagram import ( NetworkDiagramCreate, NetworkDiagramUpdate, NetworkDiagramResponse, NetworkDiagramListItem, AIGenerateRequest, AIGenerateResponse, DiagramImportRequest, DiagramImportResponse, DiagramExportResponse, DiagramNode, ) from app.services import network_diagram_ai_service import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"]) async def _get_diagram_or_404( diagram_id: UUID, team_id: UUID, db: AsyncSession, ) -> NetworkDiagram: """Fetch a diagram and verify team ownership.""" diagram = await db.get(NetworkDiagram, diagram_id) if not diagram or diagram.team_id != team_id or diagram.is_archived: raise HTTPException(status_code=404, detail="Diagram not found") return diagram def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse: """Convert ORM object to response schema.""" return NetworkDiagramResponse.model_validate(diagram) def _diagram_to_list_item(diagram: NetworkDiagram) -> NetworkDiagramListItem: """Convert ORM object to list item schema.""" nodes = diagram.nodes if isinstance(diagram.nodes, list) else [] return NetworkDiagramListItem( id=diagram.id, name=diagram.name, client_name=diagram.client_name, description=diagram.description, node_count=len(nodes), created_by=diagram.created_by, created_at=diagram.created_at, updated_at=diagram.updated_at, ) @router.get("/clients", response_model=list[str]) async def list_client_names( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> list[str]: """List distinct client_name values for the team (powers combobox filter).""" stmt = ( select(NetworkDiagram.client_name) .where( NetworkDiagram.team_id == current_user.team_id, NetworkDiagram.is_archived.is_(False), NetworkDiagram.client_name.isnot(None), NetworkDiagram.client_name != "", ) .distinct() .order_by(NetworkDiagram.client_name) ) result = await db.execute(stmt) return [row[0] for row in result.all()] @router.get("/", response_model=list[NetworkDiagramListItem]) async def list_diagrams( db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], client_name: str | None = Query(default=None), search: str | None = Query(default=None), ) -> list[NetworkDiagramListItem]: """List all diagrams for the team (exclude archived).""" stmt = ( select(NetworkDiagram) .where( NetworkDiagram.team_id == current_user.team_id, NetworkDiagram.is_archived.is_(False), ) .order_by(NetworkDiagram.updated_at.desc()) ) if client_name: stmt = stmt.where(NetworkDiagram.client_name == client_name) if search: search_filter = f"%{search}%" stmt = stmt.where( or_( NetworkDiagram.name.ilike(search_filter), NetworkDiagram.client_name.ilike(search_filter), ) ) result = await db.execute(stmt) rows = result.scalars().all() return [_diagram_to_list_item(r) for r in rows] @router.post("/", response_model=NetworkDiagramResponse, status_code=201) async def create_diagram( data: NetworkDiagramCreate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> NetworkDiagramResponse: """Create a new network diagram.""" diagram = NetworkDiagram( team_id=current_user.team_id, name=data.name, client_name=data.client_name, asset_name=data.asset_name, description=data.description, nodes=[n.model_dump() for n in data.nodes], edges=[e.model_dump() for e in data.edges], created_by=current_user.id, ) db.add(diagram) await db.commit() await db.refresh(diagram) return _diagram_to_response(diagram) @router.get("/{diagram_id}", response_model=NetworkDiagramResponse) async def get_diagram( diagram_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> NetworkDiagramResponse: """Get a single diagram by ID.""" diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db) return _diagram_to_response(diagram) @router.put("/{diagram_id}", response_model=NetworkDiagramResponse) async def update_diagram( diagram_id: UUID, data: NetworkDiagramUpdate, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> NetworkDiagramResponse: """Update a diagram.""" diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db) update_data = data.model_dump(exclude_unset=True) if "nodes" in update_data and update_data["nodes"] is not None: update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]] if "edges" in update_data and update_data["edges"] is not None: update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]] for field, value in update_data.items(): setattr(diagram, field, value) diagram.updated_at = datetime.now(timezone.utc) await db.commit() await db.refresh(diagram) return _diagram_to_response(diagram) @router.delete("/{diagram_id}", status_code=204) async def archive_diagram( diagram_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> None: """Soft delete (archive) a diagram.""" diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db) diagram.is_archived = True diagram.updated_at = datetime.now(timezone.utc) await db.commit() @router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201) async def duplicate_diagram( diagram_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> NetworkDiagramResponse: """Duplicate a diagram.""" source = await _get_diagram_or_404(diagram_id, current_user.team_id, db) copy = NetworkDiagram( team_id=current_user.team_id, name=f"Copy of {source.name}", client_name=source.client_name, asset_name=source.asset_name, description=source.description, nodes=source.nodes, edges=source.edges, created_by=current_user.id, ) db.add(copy) await db.commit() await db.refresh(copy) return _diagram_to_response(copy) @router.get("/{diagram_id}/export", response_model=DiagramExportResponse) async def export_diagram( diagram_id: UUID, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> DiagramExportResponse: """Export a diagram as JSON.""" diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db) nodes = [DiagramNode(**n) for n in (diagram.nodes or [])] edges_raw = diagram.edges or [] from app.schemas.network_diagram import DiagramEdge edges = [DiagramEdge(**e) for e in edges_raw] return DiagramExportResponse( schemaVersion=1, name=diagram.name, client_name=diagram.client_name, description=diagram.description, nodes=nodes, edges=edges, exportedAt=datetime.now(timezone.utc).isoformat(), ) @router.post("/import", response_model=DiagramImportResponse, status_code=201) async def import_diagram( data: DiagramImportRequest, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> DiagramImportResponse: """Import a diagram from JSON.""" # Check for unknown device type slugs available_stmt = select(DeviceType.slug).where( or_( DeviceType.is_system.is_(True), DeviceType.team_id == current_user.team_id, ) ) result = await db.execute(available_stmt) available_slugs = {row[0] for row in result.all()} warnings: list[str] = [] for node in data.nodes: if node.type not in available_slugs: warnings.append(f"Unknown device type '{node.type}' — will render with default icon") diagram = NetworkDiagram( team_id=current_user.team_id, name=data.name, client_name=data.client_name, description=data.description, nodes=[n.model_dump() for n in data.nodes], edges=[e.model_dump() for e in data.edges], created_by=current_user.id, ) db.add(diagram) await db.commit() await db.refresh(diagram) return DiagramImportResponse( diagram=_diagram_to_response(diagram), warnings=warnings, ) @router.post("/ai-generate", response_model=AIGenerateResponse) async def ai_generate_diagram( data: AIGenerateRequest, db: Annotated[AsyncSession, Depends(get_db)], current_user: Annotated[User, Depends(get_current_active_user)], ) -> AIGenerateResponse: """Generate a network diagram from a natural language description.""" # Get available device type slugs stmt = select(DeviceType.slug).where( or_( DeviceType.is_system.is_(True), DeviceType.team_id == current_user.team_id, ) ) result = await db.execute(stmt) available_slugs = [row[0] for row in result.all()] # Parse existing node IDs from request for merge mode existing_node_ids: list[str] | None = None if data.mode == "merge" and data.existingBounds: # Client should also send node IDs, but for now we just pass bounds existing_node_ids = [] try: return await network_diagram_ai_service.generate_diagram( request=data, available_slugs=available_slugs, existing_node_ids=existing_node_ids, ) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) except Exception: logger.exception("AI diagram generation failed") raise HTTPException(status_code=500, detail="Diagram generation failed") ``` - [ ] **Step 2: Register in router.py** Add to `backend/app/api/router.py`: Import line: ```python from app.api.endpoints import network_diagrams ``` Registration line (add before `ai_sessions` to avoid `/{id}` path conflicts): ```python api_router.include_router(network_diagrams.router) ``` - [ ] **Step 3: Verify backend starts cleanly** Run: ```bash cd backend && uvicorn app.main:app --reload & sleep 3 curl -s http://localhost:8000/api/v1/network-diagrams/ -H "Authorization: Bearer invalid" | head -5 kill %1 ``` Expected: Returns 401 (unauthorized), confirming the endpoint is registered. - [ ] **Step 4: Commit** ```bash git add backend/app/api/endpoints/network_diagrams.py backend/app/api/router.py git commit -m "feat: add network diagrams CRUD + AI generate + export/import router" ``` --- ## Task 7: Frontend TypeScript Types **Files:** - Create: `frontend/src/types/network-diagram.ts` - Modify: `frontend/src/types/index.ts` - [ ] **Step 1: Create TypeScript interfaces** Create `frontend/src/types/network-diagram.ts`: ```typescript export interface DeviceProperties { hostname: string | null ip: string | null subnet: string | null vendor: string | null model: string | null role: string | null vlan: string | null notes: string | null status: 'unknown' | 'online' | 'offline' | 'degraded' } export interface DiagramNode { id: string type: string label: string position: { x: number; y: number } properties: DeviceProperties } export interface DiagramEdge { id: string source: string target: string label: string | null connectionType: string speed: string | null notes: string | null } export interface DeviceTypeResponse { id: string slug: string label: string category: string is_system: boolean team_id: string | null sort_order: number created_at: string } export interface DeviceTypeCreate { slug: string label: string category: string sort_order?: number } export interface NetworkDiagramResponse { id: string team_id: string name: string client_name: string | null asset_name: string | null description: string | null nodes: DiagramNode[] edges: DiagramEdge[] thumbnail_url: string | null is_archived: boolean created_by: string | null created_at: string updated_at: string } export interface NetworkDiagramListItem { id: string name: string client_name: string | null description: string | null node_count: number created_by: string | null created_at: string updated_at: string } export interface NetworkDiagramCreate { name: string client_name?: string | null asset_name?: string | null description?: string | null nodes?: DiagramNode[] edges?: DiagramEdge[] } export interface NetworkDiagramUpdate { name?: string client_name?: string | null asset_name?: string | null description?: string | null nodes?: DiagramNode[] edges?: DiagramEdge[] } export interface AIGenerateRequest { description: string client_name?: string | null mode: 'replace' | 'merge' existingBounds?: { minX: number; maxX: number; minY: number; maxY: number } | null } export interface AIGenerateResponse { nodes: DiagramNode[] edges: DiagramEdge[] suggestedName: string | null notes: string | null } export interface DiagramImportData { schemaVersion: number name: string client_name?: string | null description?: string | null nodes: DiagramNode[] edges: DiagramEdge[] } export interface DiagramImportResponse { diagram: NetworkDiagramResponse warnings: string[] } export interface DiagramExportResponse { schemaVersion: number name: string client_name: string | null description: string | null nodes: DiagramNode[] edges: DiagramEdge[] exportedAt: string } ``` - [ ] **Step 2: Export from types/index.ts** Add to `frontend/src/types/index.ts`: ```typescript export * from './network-diagram' ``` - [ ] **Step 3: Commit** ```bash git add frontend/src/types/network-diagram.ts frontend/src/types/index.ts git commit -m "feat: add TypeScript types for network diagrams" ``` --- ## Task 8: Frontend API Clients **Files:** - Create: `frontend/src/api/deviceTypes.ts` - Create: `frontend/src/api/networkDiagrams.ts` - Modify: `frontend/src/api/index.ts` - [ ] **Step 1: Create device types API client** Create `frontend/src/api/deviceTypes.ts`: ```typescript import apiClient from './client' import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types' export const deviceTypesApi = { async list(): Promise { const response = await apiClient.get('/device-types/') return response.data }, async create(data: DeviceTypeCreate): Promise { const response = await apiClient.post('/device-types/', data) return response.data }, async update(id: string, data: Partial): Promise { const response = await apiClient.put(`/device-types/${id}`, data) return response.data }, async remove(id: string): Promise { await apiClient.delete(`/device-types/${id}`) }, } ``` - [ ] **Step 2: Create network diagrams API client** Create `frontend/src/api/networkDiagrams.ts`: ```typescript import apiClient from './client' import type { NetworkDiagramResponse, NetworkDiagramListItem, NetworkDiagramCreate, NetworkDiagramUpdate, AIGenerateRequest, AIGenerateResponse, DiagramImportData, DiagramImportResponse, DiagramExportResponse, } from '@/types' export const networkDiagramsApi = { async list(params?: { client_name?: string; search?: string }): Promise { const response = await apiClient.get('/network-diagrams/', { params }) return response.data }, async get(id: string): Promise { const response = await apiClient.get(`/network-diagrams/${id}`) return response.data }, async create(data: NetworkDiagramCreate): Promise { const response = await apiClient.post('/network-diagrams/', data) return response.data }, async update(id: string, data: NetworkDiagramUpdate): Promise { const response = await apiClient.put(`/network-diagrams/${id}`, data) return response.data }, async archive(id: string): Promise { await apiClient.delete(`/network-diagrams/${id}`) }, async duplicate(id: string): Promise { const response = await apiClient.post(`/network-diagrams/${id}/duplicate`) return response.data }, async exportJson(id: string): Promise { const response = await apiClient.get(`/network-diagrams/${id}/export`) return response.data }, async importJson(data: DiagramImportData): Promise { const response = await apiClient.post('/network-diagrams/import', data) return response.data }, async aiGenerate(data: AIGenerateRequest): Promise { const response = await apiClient.post('/network-diagrams/ai-generate', data) return response.data }, async listClients(): Promise { const response = await apiClient.get('/network-diagrams/clients') return response.data }, } ``` - [ ] **Step 3: Export from api/index.ts** Add to `frontend/src/api/index.ts`: ```typescript export { deviceTypesApi } from './deviceTypes' export { networkDiagramsApi } from './networkDiagrams' ``` - [ ] **Step 4: Commit** ```bash git add frontend/src/api/deviceTypes.ts frontend/src/api/networkDiagrams.ts frontend/src/api/index.ts git commit -m "feat: add frontend API clients for device types and network diagrams" ``` --- ## Task 9: Device Registry + Custom Node + Edge **Files:** - Create: `frontend/src/components/network/nodes/deviceRegistry.ts` - Create: `frontend/src/components/network/nodes/DeviceNode.tsx` - Create: `frontend/src/components/network/nodes/nodeTypes.ts` - Create: `frontend/src/components/network/edges/ConnectionEdge.tsx` - Create: `frontend/src/components/network/edges/edgeTypes.ts` - [ ] **Step 1: Create the device rendering registry** Create `frontend/src/components/network/nodes/deviceRegistry.ts`: ```typescript import type { LucideIcon } from 'lucide-react' import { Network, Layers, Shield, Wifi, Server, Monitor, Box, Cloud, Printer, Smartphone, HardDrive, Scale, Database, CloudCog, Cpu, Tablet, Laptop, BatteryCharging, LayoutGrid, RectangleVertical, Cable, Camera, KeyRound, } from 'lucide-react' export interface DeviceRenderConfig { icon: LucideIcon color: string } /** Explicit icon mapping for system device types */ const SYSTEM_DEVICE_ICONS: Record = { // Network 'router': { icon: Network, color: 'var(--color-accent)' }, 'switch': { icon: Layers, color: 'var(--color-text-muted-foreground)' }, 'firewall': { icon: Shield, color: 'var(--color-accent)' }, 'access-point': { icon: Wifi, color: 'var(--color-text-muted-foreground)' }, 'load-balancer': { icon: Scale, color: 'var(--color-text-muted-foreground)' }, // Compute 'server': { icon: Server, color: 'var(--color-text-muted-foreground)' }, 'workstation': { icon: Monitor, color: 'var(--color-text-muted-foreground)' }, 'vm': { icon: Box, color: 'var(--color-text-muted-foreground)' }, 'container': { icon: Cpu, color: 'var(--color-text-muted-foreground)' }, // Storage 'nas': { icon: Database, color: 'var(--color-text-muted-foreground)' }, 'san': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' }, 'cloud-storage': { icon: CloudCog, color: 'var(--color-text-muted-foreground)' }, // Cloud 'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' }, 'aws': { icon: Cloud, color: 'var(--color-text-muted-foreground)' }, 'azure': { icon: Cloud, color: 'var(--color-text-muted-foreground)' }, 'gcp': { icon: Cloud, color: 'var(--color-text-muted-foreground)' }, // Endpoint 'printer': { icon: Printer, color: 'var(--color-text-muted-foreground)' }, 'phone': { icon: Smartphone, color: 'var(--color-text-muted-foreground)' }, 'iot': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' }, 'camera': { icon: Camera, color: 'var(--color-text-muted-foreground)' }, 'tablet': { icon: Tablet, color: 'var(--color-text-muted-foreground)' }, 'laptop': { icon: Laptop, color: 'var(--color-text-muted-foreground)' }, // Infrastructure 'ups': { icon: BatteryCharging, color: 'var(--color-text-muted-foreground)' }, 'pdu': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' }, 'rack': { icon: RectangleVertical, color: 'var(--color-text-muted-foreground)' }, 'patch-panel': { icon: Cable, color: 'var(--color-text-muted-foreground)' }, // Security 'nvr': { icon: Camera, color: 'var(--color-text-muted-foreground)' }, 'badge-reader': { icon: KeyRound, color: 'var(--color-text-muted-foreground)' }, } /** Category-based fallback for custom device types */ const CATEGORY_DEFAULTS: Record = { 'network': { icon: Network, color: 'var(--color-text-muted-foreground)' }, 'compute': { icon: Server, color: 'var(--color-text-muted-foreground)' }, 'storage': { icon: Database, color: 'var(--color-text-muted-foreground)' }, 'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' }, 'endpoint': { icon: Monitor, color: 'var(--color-text-muted-foreground)' }, 'infrastructure': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' }, 'security': { icon: Shield, color: 'var(--color-text-muted-foreground)' }, } const FALLBACK: DeviceRenderConfig = { icon: Box, color: 'var(--color-text-muted-foreground)' } /** * Resolve a device type slug to its rendering config. * Falls back to category default, then generic default. */ export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig { if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug] if (category && CATEGORY_DEFAULTS[category]) return CATEGORY_DEFAULTS[category] return FALLBACK } /** All category labels for display */ export const CATEGORY_LABELS: Record = { 'network': 'Network', 'compute': 'Compute', 'storage': 'Storage', 'cloud': 'Cloud', 'endpoint': 'Endpoints', 'infrastructure': 'Infrastructure', 'security': 'Security', } /** Category display order */ export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security'] ``` - [ ] **Step 2: Create the DeviceNode component** Create `frontend/src/components/network/nodes/DeviceNode.tsx`: ```tsx import { memo } from 'react' import { Handle, Position, type NodeProps } from '@xyflow/react' import { cn } from '@/lib/utils' import { getDeviceRenderConfig } from './deviceRegistry' import type { DeviceProperties } from '@/types' export interface DeviceNodeData { label: string deviceType: string category?: string properties: DeviceProperties [key: string]: unknown } const STATUS_COLORS: Record = { online: 'bg-emerald-400', offline: 'bg-red-400', degraded: 'bg-yellow-400', unknown: 'bg-gray-500', } function DeviceNodeComponent({ data, selected }: NodeProps) { const nodeData = data as unknown as DeviceNodeData const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category) const status = nodeData.properties?.status || 'unknown' const ip = nodeData.properties?.ip return (
{/* Status dot */}
{/* Icon */} {/* Label */} {nodeData.label} {/* IP address */} {ip && ( {ip} )} {/* Connection handles — visible on hover */}
) } export const DeviceNode = memo(DeviceNodeComponent) ``` - [ ] **Step 3: Create node type registry** Create `frontend/src/components/network/nodes/nodeTypes.ts`: ```typescript import { DeviceNode } from './DeviceNode' export const nodeTypes = { device: DeviceNode, } ``` - [ ] **Step 4: Create the ConnectionEdge component** Create `frontend/src/components/network/edges/ConnectionEdge.tsx`: ```tsx import { memo } from 'react' import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react' interface ConnectionEdgeData { connectionType?: string speed?: string | null notes?: string | null [key: string]: unknown } const CONNECTION_STYLES: Record = { ethernet: { stroke: '#60a5fa', strokeWidth: 2 }, fiber: { stroke: '#34d399', strokeWidth: 3 }, wifi: { stroke: '#a78bfa', strokeDasharray: '3,3', strokeWidth: 2 }, vpn: { stroke: '#eab308', strokeDasharray: '8,4', strokeWidth: 2 }, vlan: { stroke: '#848b9b', strokeWidth: 2 }, wan: { stroke: '#f87171', strokeDasharray: '12,4', strokeWidth: 2 }, } const DEFAULT_STYLE = { stroke: '#848b9b', strokeWidth: 2 } function ConnectionEdgeComponent(props: EdgeProps) { const edgeData = props.data as ConnectionEdgeData | undefined const connectionType = edgeData?.connectionType || 'ethernet' const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE return ( <> {props.label && (
{props.label}
)} ) } export const ConnectionEdge = memo(ConnectionEdgeComponent) ``` - [ ] **Step 5: Create edge type registry** Create `frontend/src/components/network/edges/edgeTypes.ts`: ```typescript import { ConnectionEdge } from './ConnectionEdge' export const edgeTypes = { connection: ConnectionEdge, } ``` - [ ] **Step 6: Commit** ```bash git add frontend/src/components/network/nodes/ frontend/src/components/network/edges/ git commit -m "feat: add device registry, DeviceNode, ConnectionEdge for React Flow" ``` --- ## Task 10: DeviceToolbar Panel **Files:** - Create: `frontend/src/components/network/panels/DeviceToolbar.tsx` - [ ] **Step 1: Create the DeviceToolbar component** Create `frontend/src/components/network/panels/DeviceToolbar.tsx`: ```tsx import { useState, useMemo, useCallback } from 'react' import { Search, Plus, ChevronDown, ChevronRight, X } from 'lucide-react' import { cn } from '@/lib/utils' import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry' import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types' import { deviceTypesApi } from '@/api' interface DeviceToolbarProps { deviceTypes: DeviceTypeResponse[] onDeviceTypesChange: () => void } export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) { const [search, setSearch] = useState('') const [collapsedCategories, setCollapsedCategories] = useState>(new Set()) const [showAddForm, setShowAddForm] = useState(false) const [newType, setNewType] = useState({ slug: '', label: '', category: 'network' }) const [addError, setAddError] = useState(null) const [addLoading, setAddLoading] = useState(false) const filteredByCategory = useMemo(() => { const lower = search.toLowerCase() const filtered = search ? deviceTypes.filter(dt => dt.label.toLowerCase().includes(lower) || dt.slug.toLowerCase().includes(lower)) : deviceTypes const grouped: Record = {} for (const dt of filtered) { if (!grouped[dt.category]) grouped[dt.category] = [] grouped[dt.category].push(dt) } return grouped }, [deviceTypes, search]) const toggleCategory = useCallback((cat: string) => { setCollapsedCategories(prev => { const next = new Set(prev) if (next.has(cat)) next.delete(cat) else next.add(cat) return next }) }, []) const handleDragStart = useCallback((e: React.DragEvent, deviceType: DeviceTypeResponse) => { e.dataTransfer.setData('application/reactflow-device', JSON.stringify({ slug: deviceType.slug, label: deviceType.label, category: deviceType.category, })) e.dataTransfer.effectAllowed = 'move' }, []) const handleAddType = useCallback(async () => { if (!newType.slug || !newType.label) { setAddError('Slug and label are required') return } setAddLoading(true) setAddError(null) try { await deviceTypesApi.create(newType) setNewType({ slug: '', label: '', category: 'network' }) setShowAddForm(false) onDeviceTypesChange() } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Failed to create device type' setAddError(msg) } finally { setAddLoading(false) } }, [newType, onDeviceTypesChange]) return (
{/* Search */}
setSearch(e.target.value)} className="w-full rounded-md border border-default bg-input pl-8 pr-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none" />
{/* Device list by category */}
{CATEGORY_ORDER.map(cat => { const items = filteredByCategory[cat] if (!items?.length) return null const collapsed = collapsedCategories.has(cat) return (
{!collapsed && (
{items.map(dt => { const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category) return (
handleDragStart(e, dt)} className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing" > {dt.label}
) })}
)}
) })}
{/* Add custom type */}
{!showAddForm ? ( ) : (
New Type
setNewType(prev => ({ ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))} className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none" /> setNewType(prev => ({ ...prev, label: e.target.value }))} className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none" /> {addError &&

{addError}

}
)}
) } ``` - [ ] **Step 2: Commit** ```bash git add frontend/src/components/network/panels/DeviceToolbar.tsx git commit -m "feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation" ``` --- ## Task 11: PropertiesPanel **Files:** - Create: `frontend/src/components/network/panels/PropertiesPanel.tsx` - [ ] **Step 1: Create the PropertiesPanel component** Create `frontend/src/components/network/panels/PropertiesPanel.tsx`: ```tsx import { useCallback } from 'react' import { Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import type { DeviceProperties, DiagramEdge } from '@/types' import type { Node, Edge } from '@xyflow/react' import type { DeviceNodeData } from '../nodes/DeviceNode' interface PropertiesPanelProps { selectedNode: Node | null selectedEdge: Edge | null onNodeUpdate: (nodeId: string, data: Partial) => void onEdgeUpdate: (edgeId: string, data: Partial) => void onDeleteNode: (nodeId: string) => void onDeleteEdge: (edgeId: string) => void } const STATUS_OPTIONS = ['unknown', 'online', 'offline', 'degraded'] as const const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const function FieldLabel({ children }: { children: React.ReactNode }) { return ( ) } function FieldInput({ value, onChange, placeholder, mono }: { value: string onChange: (val: string) => void placeholder?: string mono?: boolean }) { return ( onChange(e.target.value)} placeholder={placeholder} className={cn( 'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none', mono && 'font-mono', )} /> ) } export function PropertiesPanel({ selectedNode, selectedEdge, onNodeUpdate, onEdgeUpdate, onDeleteNode, onDeleteEdge, }: PropertiesPanelProps) { const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => { if (!selectedNode) return const nodeData = selectedNode.data as unknown as DeviceNodeData onNodeUpdate(selectedNode.id, { properties: { ...nodeData.properties, [field]: value }, } as Partial) }, [selectedNode, onNodeUpdate]) const handleLabelChange = useCallback((value: string) => { if (!selectedNode) return onNodeUpdate(selectedNode.id, { label: value } as Partial) }, [selectedNode, onNodeUpdate]) // Nothing selected if (!selectedNode && !selectedEdge) { return (

Select a device or connection to edit its properties

) } // Edge selected if (selectedEdge) { const edgeData = (selectedEdge.data || {}) as Record const connectionType = (edgeData.connectionType as string) || 'ethernet' const isCustomType = !CONNECTION_TYPE_OPTIONS.includes(connectionType as typeof CONNECTION_TYPE_OPTIONS[number]) return (

Connection

Label onEdgeUpdate(selectedEdge.id, { label: val || null })} placeholder="Connection label" />
Connection Type {isCustomType && ( onEdgeUpdate(selectedEdge.id, { connectionType: val })} placeholder="Custom type name" /> )}
Speed onEdgeUpdate(selectedEdge.id, { speed: val || null })} placeholder="e.g. 1 Gbps" />
Notes onEdgeUpdate(selectedEdge.id, { notes: val || null })} placeholder="Port info, cable type..." mono />
) } // Node selected const nodeData = selectedNode!.data as unknown as DeviceNodeData const props = nodeData.properties || {} as DeviceProperties return (

Device Properties

Label
Hostname handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
IP Address handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
Subnet handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
Vendor handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
Model handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
Role handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
VLAN handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
Status
Notes