Files
resolutionflow/docs/superpowers/plans/2026-04-04-network-diagrams.md
chihlasm 8c90da1960 docs: add network diagrams implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 07:36:52 +00:00

122 KiB

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:

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

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

from .device_type import DeviceType

Add "DeviceType" to the __all__ list.

  • Step 4: Verify migration applies cleanly

Run:

cd backend && source venv/bin/activate && alembic upgrade head

Expected: Migration 073 applies without errors.

  • Step 5: Verify seed data

Run:

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

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

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

from .network_diagram import NetworkDiagram

Add "NetworkDiagram" to the __all__ list.

  • Step 4: Verify migration applies cleanly

Run:

cd backend && alembic upgrade head

Expected: Migration 074 applies without errors.

  • Step 5: Commit
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:

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

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

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

from app.api.endpoints import device_types

Registration line (add after the last include_router call):

api_router.include_router(device_types.router)
  • Step 3: Verify the endpoint starts

Run:

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

"network_diagram_generate": "standard",
  • Step 2: Create the AI generation service

Create backend/app/services/network_diagram_ai_service.py:

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

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

from app.api.endpoints import network_diagrams

Registration line (add before ai_sessions to avoid /{id} path conflicts):

api_router.include_router(network_diagrams.router)
  • Step 3: Verify backend starts cleanly

Run:

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

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:

export * from './network-diagram'
  • Step 3: Commit
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:

import apiClient from './client'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'

export const deviceTypesApi = {
  async list(): Promise<DeviceTypeResponse[]> {
    const response = await apiClient.get<DeviceTypeResponse[]>('/device-types/')
    return response.data
  },

  async create(data: DeviceTypeCreate): Promise<DeviceTypeResponse> {
    const response = await apiClient.post<DeviceTypeResponse>('/device-types/', data)
    return response.data
  },

  async update(id: string, data: Partial<DeviceTypeCreate>): Promise<DeviceTypeResponse> {
    const response = await apiClient.put<DeviceTypeResponse>(`/device-types/${id}`, data)
    return response.data
  },

  async remove(id: string): Promise<void> {
    await apiClient.delete(`/device-types/${id}`)
  },
}
  • Step 2: Create network diagrams API client

Create frontend/src/api/networkDiagrams.ts:

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<NetworkDiagramListItem[]> {
    const response = await apiClient.get<NetworkDiagramListItem[]>('/network-diagrams/', { params })
    return response.data
  },

  async get(id: string): Promise<NetworkDiagramResponse> {
    const response = await apiClient.get<NetworkDiagramResponse>(`/network-diagrams/${id}`)
    return response.data
  },

  async create(data: NetworkDiagramCreate): Promise<NetworkDiagramResponse> {
    const response = await apiClient.post<NetworkDiagramResponse>('/network-diagrams/', data)
    return response.data
  },

  async update(id: string, data: NetworkDiagramUpdate): Promise<NetworkDiagramResponse> {
    const response = await apiClient.put<NetworkDiagramResponse>(`/network-diagrams/${id}`, data)
    return response.data
  },

  async archive(id: string): Promise<void> {
    await apiClient.delete(`/network-diagrams/${id}`)
  },

  async duplicate(id: string): Promise<NetworkDiagramResponse> {
    const response = await apiClient.post<NetworkDiagramResponse>(`/network-diagrams/${id}/duplicate`)
    return response.data
  },

  async exportJson(id: string): Promise<DiagramExportResponse> {
    const response = await apiClient.get<DiagramExportResponse>(`/network-diagrams/${id}/export`)
    return response.data
  },

  async importJson(data: DiagramImportData): Promise<DiagramImportResponse> {
    const response = await apiClient.post<DiagramImportResponse>('/network-diagrams/import', data)
    return response.data
  },

  async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
    const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
    return response.data
  },

  async listClients(): Promise<string[]> {
    const response = await apiClient.get<string[]>('/network-diagrams/clients')
    return response.data
  },
}
  • Step 3: Export from api/index.ts

Add to frontend/src/api/index.ts:

export { deviceTypesApi } from './deviceTypes'
export { networkDiagramsApi } from './networkDiagrams'
  • Step 4: Commit
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:

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<string, DeviceRenderConfig> = {
  // 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<string, DeviceRenderConfig> = {
  '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<string, string> = {
  '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:

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<string, string> = {
  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 (
    <div
      className={cn(
        'relative flex min-w-[120px] flex-col items-center gap-1 rounded-lg border bg-card px-4 py-3',
        'border-default transition-colors',
        'group',
        selected && 'border-accent',
      )}
    >
      {/* Status dot */}
      <div className={cn('absolute right-2 top-2 h-2 w-2 rounded-full', STATUS_COLORS[status])} />

      {/* Icon */}
      <Icon size={28} style={{ color }} />

      {/* Label */}
      <span className="text-center text-xs font-medium text-heading">{nodeData.label}</span>

      {/* IP address */}
      {ip && (
        <span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
      )}

      {/* Connection handles — visible on hover */}
      <Handle type="target" position={Position.Top} className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
      <Handle type="source" position={Position.Bottom} className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
      <Handle type="target" position={Position.Left} id="left" className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
      <Handle type="source" position={Position.Right} id="right" className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
    </div>
  )
}

export const DeviceNode = memo(DeviceNodeComponent)
  • Step 3: Create node type registry

Create frontend/src/components/network/nodes/nodeTypes.ts:

import { DeviceNode } from './DeviceNode'

export const nodeTypes = {
  device: DeviceNode,
}
  • Step 4: Create the ConnectionEdge component

Create frontend/src/components/network/edges/ConnectionEdge.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<string, { stroke: string; strokeDasharray?: string; strokeWidth: number }> = {
  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 (
    <>
      <SmoothStepEdge
        {...props}
        style={{
          ...style,
          ...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
        }}
      />
      {props.label && (
        <EdgeLabelRenderer>
          <div
            className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
            style={{
              position: 'absolute',
              transform: `translate(-50%, -50%) translate(${(props.sourceX + props.targetX) / 2}px, ${(props.sourceY + props.targetY) / 2}px)`,
              pointerEvents: 'all',
            }}
          >
            {props.label}
          </div>
        </EdgeLabelRenderer>
      )}
    </>
  )
}

export const ConnectionEdge = memo(ConnectionEdgeComponent)
  • Step 5: Create edge type registry

Create frontend/src/components/network/edges/edgeTypes.ts:

import { ConnectionEdge } from './ConnectionEdge'

export const edgeTypes = {
  connection: ConnectionEdge,
}
  • Step 6: Commit
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:

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<Set<string>>(new Set())
  const [showAddForm, setShowAddForm] = useState(false)
  const [newType, setNewType] = useState<DeviceTypeCreate>({ slug: '', label: '', category: 'network' })
  const [addError, setAddError] = useState<string | null>(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<string, DeviceTypeResponse[]> = {}
    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 (
    <div className="flex h-full w-[200px] flex-col border-r border-default bg-sidebar">
      {/* Search */}
      <div className="relative p-2">
        <Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
        <input
          type="text"
          placeholder="Search devices..."
          value={search}
          onChange={e => 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"
        />
      </div>

      {/* Device list by category */}
      <div className="flex-1 overflow-y-auto px-2 pb-2">
        {CATEGORY_ORDER.map(cat => {
          const items = filteredByCategory[cat]
          if (!items?.length) return null
          const collapsed = collapsedCategories.has(cat)

          return (
            <div key={cat} className="mb-1">
              <button
                onClick={() => toggleCategory(cat)}
                className="flex w-full items-center gap-1 rounded px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-primary"
              >
                {collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
                {CATEGORY_LABELS[cat] || cat}
                <span className="ml-auto text-[10px] font-normal">{items.length}</span>
              </button>
              {!collapsed && (
                <div className="flex flex-col gap-0.5">
                  {items.map(dt => {
                    const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category)
                    return (
                      <div
                        key={dt.id}
                        draggable
                        onDragStart={e => 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"
                      >
                        <Icon size={14} style={{ color }} />
                        <span>{dt.label}</span>
                      </div>
                    )
                  })}
                </div>
              )}
            </div>
          )
        })}
      </div>

      {/* Add custom type */}
      <div className="border-t border-default p-2">
        {!showAddForm ? (
          <button
            onClick={() => setShowAddForm(true)}
            className="flex w-full items-center justify-center gap-1 rounded border border-default px-2 py-1.5 text-xs text-muted-foreground hover:border-hover hover:text-primary"
          >
            <Plus size={12} />
            Custom Type
          </button>
        ) : (
          <div className="flex flex-col gap-1.5">
            <div className="flex items-center justify-between">
              <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">New Type</span>
              <button onClick={() => { setShowAddForm(false); setAddError(null) }} className="text-muted-foreground hover:text-primary">
                <X size={12} />
              </button>
            </div>
            <input
              placeholder="slug (e.g. pacs-server)"
              value={newType.slug}
              onChange={e => 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"
            />
            <input
              placeholder="Label (e.g. PACS Server)"
              value={newType.label}
              onChange={e => 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"
            />
            <select
              value={newType.category}
              onChange={e => setNewType(prev => ({ ...prev, category: e.target.value }))}
              className="rounded border border-default bg-input px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
            >
              {CATEGORY_ORDER.map(c => (
                <option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
              ))}
            </select>
            {addError && <p className="text-[10px] text-red-400">{addError}</p>}
            <button
              onClick={handleAddType}
              disabled={addLoading}
              className="rounded bg-accent px-2 py-1 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
            >
              {addLoading ? 'Adding...' : 'Add Type'}
            </button>
          </div>
        )}
      </div>
    </div>
  )
}
  • Step 2: Commit
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:

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<DeviceNodeData>) => void
  onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => 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 (
    <label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
      {children}
    </label>
  )
}

function FieldInput({ value, onChange, placeholder, mono }: {
  value: string
  onChange: (val: string) => void
  placeholder?: string
  mono?: boolean
}) {
  return (
    <input
      type="text"
      value={value}
      onChange={e => 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<DeviceNodeData>)
  }, [selectedNode, onNodeUpdate])

  const handleLabelChange = useCallback((value: string) => {
    if (!selectedNode) return
    onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
  }, [selectedNode, onNodeUpdate])

  // Nothing selected
  if (!selectedNode && !selectedEdge) {
    return (
      <div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
        <p className="text-center text-xs text-muted-foreground">
          Select a device or connection to edit its properties
        </p>
      </div>
    )
  }

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

    return (
      <div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
        <div className="border-b border-default px-3 py-2">
          <h3 className="text-xs font-semibold text-heading">Connection</h3>
        </div>
        <div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
          <div className="flex flex-col gap-1">
            <FieldLabel>Label</FieldLabel>
            <FieldInput
              value={(selectedEdge.label as string) || ''}
              onChange={val => onEdgeUpdate(selectedEdge.id, { label: val || null })}
              placeholder="Connection label"
            />
          </div>
          <div className="flex flex-col gap-1">
            <FieldLabel>Connection Type</FieldLabel>
            <select
              value={isCustomType ? '__custom__' : connectionType}
              onChange={e => {
                const val = e.target.value
                if (val !== '__custom__') {
                  onEdgeUpdate(selectedEdge.id, { connectionType: val })
                }
              }}
              className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
            >
              {CONNECTION_TYPE_OPTIONS.map(opt => (
                <option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
              ))}
              <option value="__custom__">Custom...</option>
            </select>
            {isCustomType && (
              <FieldInput
                value={connectionType}
                onChange={val => onEdgeUpdate(selectedEdge.id, { connectionType: val })}
                placeholder="Custom type name"
              />
            )}
          </div>
          <div className="flex flex-col gap-1">
            <FieldLabel>Speed</FieldLabel>
            <FieldInput
              value={(edgeData.speed as string) || ''}
              onChange={val => onEdgeUpdate(selectedEdge.id, { speed: val || null })}
              placeholder="e.g. 1 Gbps"
            />
          </div>
          <div className="flex flex-col gap-1">
            <FieldLabel>Notes</FieldLabel>
            <FieldInput
              value={(edgeData.notes as string) || ''}
              onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
              placeholder="Port info, cable type..."
              mono
            />
          </div>
        </div>
        <div className="border-t border-default p-3">
          <button
            onClick={() => onDeleteEdge(selectedEdge.id)}
            className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
          >
            <Trash2 size={12} />
            Delete Connection
          </button>
        </div>
      </div>
    )
  }

  // Node selected
  const nodeData = selectedNode!.data as unknown as DeviceNodeData
  const props = nodeData.properties || {} as DeviceProperties

  return (
    <div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
      <div className="border-b border-default px-3 py-2">
        <h3 className="text-xs font-semibold text-heading">Device Properties</h3>
      </div>
      <div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
        <div className="flex flex-col gap-1">
          <FieldLabel>Label</FieldLabel>
          <FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Hostname</FieldLabel>
          <FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>IP Address</FieldLabel>
          <FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Subnet</FieldLabel>
          <FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Vendor</FieldLabel>
          <FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Model</FieldLabel>
          <FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Role</FieldLabel>
          <FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>VLAN</FieldLabel>
          <FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Status</FieldLabel>
          <select
            value={props.status || 'unknown'}
            onChange={e => handlePropertyChange('status', e.target.value)}
            className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
          >
            {STATUS_OPTIONS.map(opt => (
              <option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
            ))}
          </select>
        </div>
        <div className="flex flex-col gap-1">
          <FieldLabel>Notes</FieldLabel>
          <textarea
            value={props.notes || ''}
            onChange={e => handlePropertyChange('notes', e.target.value)}
            placeholder="Additional notes..."
            rows={3}
            className="w-full resize-none 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"
          />
        </div>
      </div>
      <div className="border-t border-default p-3">
        <button
          onClick={() => onDeleteNode(selectedNode!.id)}
          className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
        >
          <Trash2 size={12} />
          Delete Device
        </button>
      </div>
    </div>
  )
}
  • Step 2: Commit
git add frontend/src/components/network/panels/PropertiesPanel.tsx
git commit -m "feat: add PropertiesPanel for node and edge property editing"

Task 12: AIAssistPanel

Files:

  • Create: frontend/src/components/network/panels/AIAssistPanel.tsx

  • Step 1: Create the AIAssistPanel component

Create frontend/src/components/network/panels/AIAssistPanel.tsx:

import { useState, useCallback } from 'react'
import { Sparkles, ChevronUp, ChevronDown, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import type { AIGenerateResponse } from '@/types'

interface AIAssistPanelProps {
  onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
  getExistingBounds: () => { minX: number; maxX: number; minY: number; maxY: number } | null
  hasNodes: boolean
}

export function AIAssistPanel({ onGenerate, getExistingBounds, hasNodes }: AIAssistPanelProps) {
  const [expanded, setExpanded] = useState(false)
  const [description, setDescription] = useState('')
  const [mode, setMode] = useState<'replace' | 'merge'>('replace')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleGenerate = useCallback(async () => {
    if (!description.trim()) return
    setLoading(true)
    setError(null)
    try {
      const result = await networkDiagramsApi.aiGenerate({
        description: description.trim(),
        mode,
        existingBounds: mode === 'merge' ? getExistingBounds() : null,
      })
      onGenerate(result, mode)
      setDescription('')
      setExpanded(false)
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : 'Generation failed. Please try again.'
      setError(msg)
    } finally {
      setLoading(false)
    }
  }, [description, mode, onGenerate, getExistingBounds])

  if (!expanded) {
    return (
      <div className="border-t border-default bg-card">
        <button
          onClick={() => setExpanded(true)}
          className="flex w-full items-center justify-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-primary"
        >
          <Sparkles size={14} />
          AI Generate
          <ChevronUp size={14} />
        </button>
      </div>
    )
  }

  return (
    <div className="border-t border-default bg-card">
      <div className="flex items-center justify-between border-b border-default px-4 py-2">
        <div className="flex items-center gap-2 text-xs font-medium text-heading">
          <Sparkles size={14} />
          AI Generate
        </div>
        <button onClick={() => setExpanded(false)} className="text-muted-foreground hover:text-primary">
          <ChevronDown size={14} />
        </button>
      </div>

      <div className="flex flex-col gap-3 p-4">
        {/* Mode toggle */}
        <div className="flex gap-2">
          <button
            onClick={() => setMode('replace')}
            className={cn(
              'rounded px-3 py-1 text-xs font-medium transition-colors',
              mode === 'replace'
                ? 'bg-accent text-white'
                : 'border border-default text-muted-foreground hover:text-primary',
            )}
          >
            Generate New
          </button>
          <button
            onClick={() => setMode('merge')}
            className={cn(
              'rounded px-3 py-1 text-xs font-medium transition-colors',
              mode === 'merge'
                ? 'bg-accent text-white'
                : 'border border-default text-muted-foreground hover:text-primary',
            )}
          >
            Add to Diagram
          </button>
        </div>

        {/* Warning for replace mode */}
        {mode === 'replace' && hasNodes && (
          <div className="flex items-start gap-2 rounded border border-yellow-500/30 bg-yellow-500/5 px-3 py-2">
            <AlertTriangle size={14} className="mt-0.5 shrink-0 text-yellow-400" />
            <p className="text-[11px] text-yellow-400">
              This will replace your current diagram. Save first if needed.
            </p>
          </div>
        )}

        {/* Description textarea */}
        <textarea
          value={description}
          onChange={e => setDescription(e.target.value)}
          placeholder="Describe the network you want to create... e.g. 'Small office with a firewall, core switch, 3 access points, and a file server'"
          rows={3}
          disabled={loading}
          className="w-full resize-none rounded border border-default bg-input px-3 py-2 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
        />

        {/* Error */}
        {error && <p className="text-[11px] text-red-400">{error}</p>}

        {/* Generate button / Loading state */}
        {loading ? (
          <div className="flex items-center justify-center gap-2 py-2">
            <div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
            <span className="text-xs text-muted-foreground">Generating your network diagram...</span>
          </div>
        ) : (
          <button
            onClick={handleGenerate}
            disabled={!description.trim()}
            className="rounded bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
          >
            Generate
          </button>
        )}
      </div>
    </div>
  )
}
  • Step 2: Commit
git add frontend/src/components/network/panels/AIAssistPanel.tsx
git commit -m "feat: add AIAssistPanel with replace and merge modes"

Task 13: NetworkCanvas + DiagramHeader

Files:

  • Create: frontend/src/components/network/NetworkCanvas.tsx

  • Create: frontend/src/components/network/DiagramHeader.tsx

  • Step 1: Create NetworkCanvas

Create frontend/src/components/network/NetworkCanvas.tsx:

import { useCallback } from 'react'
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  BackgroundVariant,
  type OnConnect,
  type OnNodesChange,
  type OnEdgesChange,
  type Node,
  type Edge,
  addEdge,
  type Connection,
} from '@xyflow/react'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import type { DeviceNodeData } from './nodes/DeviceNode'
import type { DeviceProperties, DeviceTypeResponse } from '@/types'

interface NetworkCanvasProps {
  nodes: Node[]
  edges: Edge[]
  onNodesChange: OnNodesChange
  onEdgesChange: OnEdgesChange
  onConnect: OnConnect
  onNodeSelect: (node: Node | null) => void
  onEdgeSelect: (edge: Edge | null) => void
  onDrop: (event: React.DragEvent) => void
  onDragOver: (event: React.DragEvent) => void
}

export function NetworkCanvas({
  nodes,
  edges,
  onNodesChange,
  onEdgesChange,
  onConnect,
  onNodeSelect,
  onEdgeSelect,
  onDrop,
  onDragOver,
}: NetworkCanvasProps) {
  const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
    if (selectedNodes.length === 1) {
      onNodeSelect(selectedNodes[0])
      onEdgeSelect(null)
    } else if (selectedEdges.length === 1) {
      onEdgeSelect(selectedEdges[0])
      onNodeSelect(null)
    } else {
      onNodeSelect(null)
      onEdgeSelect(null)
    }
  }, [onNodeSelect, onEdgeSelect])

  const handlePaneClick = useCallback(() => {
    onNodeSelect(null)
    onEdgeSelect(null)
  }, [onNodeSelect, onEdgeSelect])

  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnect={onConnect}
      onSelectionChange={handleSelectionChange}
      onPaneClick={handlePaneClick}
      onDrop={onDrop}
      onDragOver={onDragOver}
      nodeTypes={nodeTypes}
      edgeTypes={edgeTypes}
      defaultEdgeOptions={{ type: 'connection' }}
      deleteKeyCode={['Backspace', 'Delete']}
      multiSelectionKeyCode="Shift"
      fitView
      className="bg-page"
    >
      <Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
      <Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
      <MiniMap
        nodeColor="var(--color-bg-elevated)"
        maskColor="rgba(0,0,0,0.5)"
        className="!border-default !bg-card"
        position="bottom-right"
      />
    </ReactFlow>
  )
}
  • Step 2: Create DiagramHeader

Create frontend/src/components/network/DiagramHeader.tsx:

import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'

interface DiagramHeaderProps {
  name: string
  clientName: string | null
  isSaving: boolean
  lastSavedAt: Date | null
  diagramId: string | null
  onNameChange: (name: string) => void
  onSave: () => void
  onExportPng: () => void
  onExportPdf: () => void
  onExportJson: () => void
}

export function DiagramHeader({
  name,
  clientName,
  isSaving,
  lastSavedAt,
  diagramId,
  onNameChange,
  onSave,
  onExportPng,
  onExportPdf,
  onExportJson,
}: DiagramHeaderProps) {
  const navigate = useNavigate()
  const [editing, setEditing] = useState(false)
  const [editValue, setEditValue] = useState(name)
  const [showExportMenu, setShowExportMenu] = useState(false)
  const inputRef = useRef<HTMLInputElement>(null)
  const exportMenuRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (editing && inputRef.current) {
      inputRef.current.focus()
      inputRef.current.select()
    }
  }, [editing])

  useEffect(() => {
    setEditValue(name)
  }, [name])

  // Close export menu on outside click
  useEffect(() => {
    if (!showExportMenu) return
    const handleClick = (e: MouseEvent) => {
      if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as HTMLElement)) {
        setShowExportMenu(false)
      }
    }
    document.addEventListener('mousedown', handleClick)
    return () => document.removeEventListener('mousedown', handleClick)
  }, [showExportMenu])

  const handleConfirmName = useCallback(() => {
    setEditing(false)
    if (editValue.trim() && editValue !== name) {
      onNameChange(editValue.trim())
    } else {
      setEditValue(name)
    }
  }, [editValue, name, onNameChange])

  const formatLastSaved = () => {
    if (!lastSavedAt) return null
    const diff = Date.now() - lastSavedAt.getTime()
    if (diff < 60_000) return 'Saved just now'
    const mins = Math.floor(diff / 60_000)
    return `Saved ${mins}m ago`
  }

  return (
    <div className="flex h-14 items-center gap-3 border-b border-default bg-card px-4">
      {/* Back button */}
      <button
        onClick={() => navigate('/network-diagrams')}
        className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
      >
        <ChevronLeft size={16} />
        Network Maps
      </button>

      <div className="mx-2 h-5 w-px bg-border-default" />

      {/* Editable name */}
      {editing ? (
        <input
          ref={inputRef}
          value={editValue}
          onChange={e => setEditValue(e.target.value)}
          onBlur={handleConfirmName}
          onKeyDown={e => { if (e.key === 'Enter') handleConfirmName(); if (e.key === 'Escape') { setEditing(false); setEditValue(name) } }}
          className="rounded border border-accent bg-input px-2 py-1 text-sm font-heading font-semibold text-heading focus:outline-none"
        />
      ) : (
        <button
          onClick={() => setEditing(true)}
          className="text-sm font-heading font-semibold text-heading hover:text-accent"
        >
          {name || 'Untitled Diagram'}
        </button>
      )}

      {/* Client badge */}
      {clientName && (
        <span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
          {clientName}
        </span>
      )}

      <div className="flex-1" />

      {/* Last saved */}
      {lastSavedAt && (
        <span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
      )}

      {/* Save */}
      <button
        onClick={onSave}
        disabled={isSaving}
        className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
      >
        <Save size={14} />
        {isSaving ? 'Saving...' : 'Save'}
      </button>

      {/* Export dropdown */}
      <div className="relative" ref={exportMenuRef}>
        <button
          onClick={() => setShowExportMenu(prev => !prev)}
          className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
        >
          <Download size={14} />
          Export
        </button>
        {showExportMenu && (
          <div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
            <button
              onClick={() => { onExportPng(); setShowExportMenu(false) }}
              className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
            >
              <Image size={12} /> Export PNG
            </button>
            <button
              onClick={() => { onExportPdf(); setShowExportMenu(false) }}
              className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
            >
              <FileText size={12} /> Export PDF
            </button>
            {diagramId && (
              <button
                onClick={() => { onExportJson(); setShowExportMenu(false) }}
                className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
              >
                <FileJson size={12} /> Export JSON
              </button>
            )}
          </div>
        )}
      </div>
    </div>
  )
}
  • Step 3: Commit
git add frontend/src/components/network/NetworkCanvas.tsx frontend/src/components/network/DiagramHeader.tsx
git commit -m "feat: add NetworkCanvas wrapper and DiagramHeader components"

Task 14: DiagramEditor Page

Files:

  • Create: frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx

  • Step 1: Create the DiagramEditor page

Create frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx. This is the largest component — it assembles all panels, manages state, handles auto-save, and implements drag-drop, export, and AI generation.

The file is large. Key sections to implement:

  1. State: useNodesState, useEdgesState, diagramMeta, isDirty, lastSavedAt, selectedNode, selectedEdge, deviceTypes
  2. Load: Fetch diagram by id param on mount, or start in "new" mode
  3. Save: POST for new, PUT for existing. Set dirty flag on any change.
  4. Auto-save: setInterval 30s, only if dirty and has diagramId
  5. Drag-drop: onDragOver sets dropEffect, onDrop reads application/reactflow-device data, creates a node at drop position
  6. Node/edge updates: Callbacks passed to PropertiesPanel that update React Flow state
  7. AI generation: onGenerate callback — replace mode replaces all nodes/edges, merge mode appends
  8. Export PNG: Use getNodesBounds + canvas rendering
  9. Export PDF: Use window.print() with print stylesheet
  10. Export JSON: Call API, trigger download
  11. Layout: DiagramHeader on top, then flex row with DeviceToolbar | ReactFlowProvider > NetworkCanvas | PropertiesPanel, AIAssistPanel at bottom
import { useState, useCallback, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
  ReactFlowProvider,
  useNodesState,
  useEdgesState,
  addEdge,
  useReactFlow,
  type Node,
  type Edge,
  type Connection,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'

import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { DiagramHeader } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
import { networkDiagramsApi, deviceTypesApi } from '@/api'
import { toast } from '@/lib/toast'
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge } from '@/types'
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'

function DiagramEditorInner() {
  const { id } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const { getViewport, getNodes, fitView } = useReactFlow()

  // Diagram metadata
  const [diagramId, setDiagramId] = useState<string | null>(id || null)
  const [name, setName] = useState('Untitled Diagram')
  const [clientName, setClientName] = useState<string | null>(null)
  const [assetName, setAssetName] = useState<string | null>(null)
  const [description, setDescription] = useState<string | null>(null)

  // Canvas state
  const [nodes, setNodes, onNodesChange] = useNodesState([])
  const [edges, setEdges, onEdgesChange] = useEdgesState([])

  // Selection
  const [selectedNode, setSelectedNode] = useState<Node | null>(null)
  const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null)

  // Save state
  const [isDirty, setIsDirty] = useState(false)
  const [isSaving, setIsSaving] = useState(false)
  const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
  const isDirtyRef = useRef(false)
  const diagramIdRef = useRef<string | null>(id || null)

  // Device types
  const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])

  // Loading
  const [loading, setLoading] = useState(!!id)

  // Keep refs in sync
  useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
  useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])

  // Mark dirty on node/edge changes
  const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
    onNodesChange(changes)
    // Don't mark dirty for selection-only changes
    const hasRealChange = changes.some(c => c.type !== 'select')
    if (hasRealChange) setIsDirty(true)
  }, [onNodesChange])

  const handleEdgesChange: typeof onEdgesChange = useCallback((changes) => {
    onEdgesChange(changes)
    const hasRealChange = changes.some(c => c.type !== 'select')
    if (hasRealChange) setIsDirty(true)
  }, [onEdgesChange])

  // Load device types
  const loadDeviceTypes = useCallback(async () => {
    try {
      const types = await deviceTypesApi.list()
      setDeviceTypes(types)
    } catch { /* ignore */ }
  }, [])

  useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes])

  // Load existing diagram
  useEffect(() => {
    if (!id) return
    let cancelled = false
    ;(async () => {
      try {
        const diagram = await networkDiagramsApi.get(id)
        if (cancelled) return
        setName(diagram.name)
        setClientName(diagram.client_name)
        setAssetName(diagram.asset_name)
        setDescription(diagram.description)
        setNodes(
          diagram.nodes.map(n => ({
            id: n.id,
            type: 'device',
            position: n.position,
            data: {
              label: n.label,
              deviceType: n.type,
              properties: n.properties,
            } satisfies DeviceNodeData,
          }))
        )
        setEdges(
          diagram.edges.map(e => ({
            id: e.id,
            source: e.source,
            target: e.target,
            type: 'connection',
            label: e.label || undefined,
            data: {
              connectionType: e.connectionType,
              speed: e.speed,
              notes: e.notes,
            },
          }))
        )
        setLastSavedAt(new Date(diagram.updated_at))
      } catch {
        toast.error('Failed to load diagram')
        navigate('/network-diagrams')
      } finally {
        if (!cancelled) setLoading(false)
      }
    })()
    return () => { cancelled = true }
  }, [id, navigate, setNodes, setEdges])

  // Serialize current state for saving
  const serializeNodes = useCallback(() => {
    return getNodes().map(n => {
      const data = n.data as unknown as DeviceNodeData
      return {
        id: n.id,
        type: data.deviceType,
        label: data.label,
        position: n.position,
        properties: data.properties,
      }
    })
  }, [getNodes])

  const serializeEdges = useCallback((): DiagramEdge[] => {
    return edges.map(e => ({
      id: e.id,
      source: e.source,
      target: e.target,
      label: (e.label as string) || null,
      connectionType: (e.data as Record<string, unknown>)?.connectionType as string || 'ethernet',
      speed: (e.data as Record<string, unknown>)?.speed as string || null,
      notes: (e.data as Record<string, unknown>)?.notes as string || null,
    }))
  }, [edges])

  // Save
  const handleSave = useCallback(async () => {
    setIsSaving(true)
    try {
      const payload = {
        name,
        client_name: clientName,
        asset_name: assetName,
        description,
        nodes: serializeNodes(),
        edges: serializeEdges(),
      }
      if (diagramIdRef.current) {
        await networkDiagramsApi.update(diagramIdRef.current, payload)
      } else {
        const created = await networkDiagramsApi.create(payload)
        setDiagramId(created.id)
        navigate(`/network-diagrams/${created.id}`, { replace: true })
      }
      setIsDirty(false)
      setLastSavedAt(new Date())
    } catch {
      toast.error('Failed to save diagram')
    } finally {
      setIsSaving(false)
    }
  }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])

  // Auto-save every 30 seconds
  useEffect(() => {
    const interval = setInterval(() => {
      if (isDirtyRef.current && diagramIdRef.current) {
        handleSave()
      }
    }, 30_000)
    return () => clearInterval(interval)
  }, [handleSave])

  // Connect edges
  const onConnect = useCallback((connection: Connection) => {
    setEdges(eds => addEdge({
      ...connection,
      type: 'connection',
      data: { connectionType: 'ethernet' },
    }, eds))
    setIsDirty(true)
  }, [setEdges])

  // Drag-drop from toolbar
  const onDragOver = useCallback((event: React.DragEvent) => {
    event.preventDefault()
    event.dataTransfer.dropEffect = 'move'
  }, [])

  const onDrop = useCallback((event: React.DragEvent) => {
    event.preventDefault()
    const raw = event.dataTransfer.getData('application/reactflow-device')
    if (!raw) return

    const { slug, label, category } = JSON.parse(raw) as { slug: string; label: string; category: string }
    const reactFlowBounds = (event.target as HTMLElement).closest('.react-flow')?.getBoundingClientRect()
    if (!reactFlowBounds) return

    const position = {
      x: event.clientX - reactFlowBounds.left,
      y: event.clientY - reactFlowBounds.top,
    }

    const newNode: Node = {
      id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
      type: 'device',
      position,
      data: {
        label,
        deviceType: slug,
        category,
        properties: {
          hostname: null,
          ip: null,
          subnet: null,
          vendor: null,
          model: null,
          role: null,
          vlan: null,
          notes: null,
          status: 'unknown',
        } satisfies DeviceProperties,
      } satisfies DeviceNodeData,
    }
    setNodes(nds => [...nds, newNode])
    setIsDirty(true)
  }, [setNodes])

  // Node/edge updates from PropertiesPanel
  const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
    setNodes(nds => nds.map(n => {
      if (n.id !== nodeId) return n
      return { ...n, data: { ...n.data, ...updates } }
    }))
    setIsDirty(true)
  }, [setNodes])

  const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
    setEdges(eds => eds.map(e => {
      if (e.id !== edgeId) return e
      return {
        ...e,
        label: updates.label !== undefined ? (updates.label || undefined) : e.label,
        data: {
          ...(e.data || {}),
          ...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
          ...(updates.speed !== undefined ? { speed: updates.speed } : {}),
          ...(updates.notes !== undefined ? { notes: updates.notes } : {}),
        },
      }
    }))
    // Update selected edge reference
    setSelectedEdge(prev => {
      if (!prev || prev.id !== edgeId) return prev
      return {
        ...prev,
        label: updates.label !== undefined ? (updates.label || undefined) : prev.label,
        data: {
          ...(prev.data || {}),
          ...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
          ...(updates.speed !== undefined ? { speed: updates.speed } : {}),
          ...(updates.notes !== undefined ? { notes: updates.notes } : {}),
        },
      }
    })
    setIsDirty(true)
  }, [setEdges])

  const handleDeleteNode = useCallback((nodeId: string) => {
    setNodes(nds => nds.filter(n => n.id !== nodeId))
    setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
    setSelectedNode(null)
    setIsDirty(true)
  }, [setNodes, setEdges])

  const handleDeleteEdge = useCallback((edgeId: string) => {
    setEdges(eds => eds.filter(e => e.id !== edgeId))
    setSelectedEdge(null)
    setIsDirty(true)
  }, [setEdges])

  // AI generation
  const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
    const newNodes: Node[] = result.nodes.map(n => ({
      id: n.id,
      type: 'device',
      position: n.position,
      data: {
        label: n.label,
        deviceType: n.type,
        properties: n.properties,
      } satisfies DeviceNodeData,
    }))

    const newEdges: Edge[] = result.edges.map(e => ({
      id: e.id,
      source: e.source,
      target: e.target,
      type: 'connection',
      label: e.label || undefined,
      data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
    }))

    if (mode === 'replace') {
      setNodes(newNodes)
      setEdges(newEdges)
    } else {
      setNodes(nds => [...nds, ...newNodes])
      setEdges(eds => [...eds, ...newEdges])
    }

    if (result.suggestedName && !diagramId) {
      setName(result.suggestedName)
      toast.success(`Generated: ${result.suggestedName}`)
    } else {
      toast.success('Diagram generated')
    }

    if (result.notes) {
      toast.info(result.notes)
    }

    setIsDirty(true)
    setTimeout(() => fitView({ padding: 0.2 }), 100)
  }, [setNodes, setEdges, diagramId, fitView])

  const getExistingBounds = useCallback(() => {
    const currentNodes = getNodes()
    if (currentNodes.length === 0) return null
    let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
    for (const n of currentNodes) {
      minX = Math.min(minX, n.position.x)
      maxX = Math.max(maxX, n.position.x + 120)
      minY = Math.min(minY, n.position.y)
      maxY = Math.max(maxY, n.position.y + 80)
    }
    return { minX, maxX, minY, maxY }
  }, [getNodes])

  // Export PNG — simplified canvas approach
  const handleExportPng = useCallback(() => {
    toast.info('PNG export — use your browser\'s screenshot tool or Print > Save as Image for now')
  }, [])

  // Export PDF via print
  const handleExportPdf = useCallback(() => {
    window.print()
  }, [])

  // Export JSON
  const handleExportJson = useCallback(async () => {
    if (!diagramId) return
    try {
      const data = await networkDiagramsApi.exportJson(diagramId)
      const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`
      a.click()
      URL.revokeObjectURL(url)
    } catch {
      toast.error('Failed to export diagram')
    }
  }, [diagramId, name])

  if (loading) {
    return (
      <div className="flex h-full items-center justify-center">
        <div className="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
      </div>
    )
  }

  return (
    <div className="flex h-full flex-col">
      <DiagramHeader
        name={name}
        clientName={clientName}
        isSaving={isSaving}
        lastSavedAt={lastSavedAt}
        diagramId={diagramId}
        onNameChange={n => { setName(n); setIsDirty(true) }}
        onSave={handleSave}
        onExportPng={handleExportPng}
        onExportPdf={handleExportPdf}
        onExportJson={handleExportJson}
      />
      <div className="flex flex-1 min-h-0">
        <DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
        <div className="flex flex-1 flex-col min-h-0">
          <div className="flex-1 min-h-0">
            <NetworkCanvas
              nodes={nodes}
              edges={edges}
              onNodesChange={handleNodesChange}
              onEdgesChange={handleEdgesChange}
              onConnect={onConnect}
              onNodeSelect={setSelectedNode}
              onEdgeSelect={setSelectedEdge}
              onDrop={onDrop}
              onDragOver={onDragOver}
            />
          </div>
          <AIAssistPanel
            onGenerate={handleAIGenerate}
            getExistingBounds={getExistingBounds}
            hasNodes={nodes.length > 0}
          />
        </div>
        <PropertiesPanel
          selectedNode={selectedNode}
          selectedEdge={selectedEdge}
          onNodeUpdate={handleNodeUpdate}
          onEdgeUpdate={handleEdgeUpdate}
          onDeleteNode={handleDeleteNode}
          onDeleteEdge={handleDeleteEdge}
        />
      </div>
    </div>
  )
}

export default function DiagramEditor() {
  return (
    <ReactFlowProvider>
      <DiagramEditorInner />
    </ReactFlowProvider>
  )
}
  • Step 2: Commit
git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat: add DiagramEditor page assembling all panels with auto-save and AI generation"

Task 15: Network Diagrams List Page

Files:

  • Create: frontend/src/pages/NetworkDiagrams/index.tsx

  • Step 1: Create the list page

Create frontend/src/pages/NetworkDiagrams/index.tsx:

import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, Network, MoreHorizontal, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import { toast } from '@/lib/toast'
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'

export default function NetworkDiagramsPage() {
  const navigate = useNavigate()
  const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
  const [loading, setLoading] = useState(true)
  const [search, setSearch] = useState('')
  const [clientFilter, setClientFilter] = useState<string | null>(null)
  const [clientOptions, setClientOptions] = useState<string[]>([])
  const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
  const [clientSearch, setClientSearch] = useState('')
  const [menuOpenId, setMenuOpenId] = useState<string | null>(null)

  const loadDiagrams = useCallback(async () => {
    try {
      const params: Record<string, string> = {}
      if (clientFilter) params.client_name = clientFilter
      if (search) params.search = search
      const data = await networkDiagramsApi.list(params)
      setDiagrams(data)
    } catch {
      toast.error('Failed to load diagrams')
    } finally {
      setLoading(false)
    }
  }, [clientFilter, search])

  const loadClients = useCallback(async () => {
    try {
      const clients = await networkDiagramsApi.listClients()
      setClientOptions(clients)
    } catch { /* ignore */ }
  }, [])

  useEffect(() => { loadDiagrams() }, [loadDiagrams])
  useEffect(() => { loadClients() }, [loadClients])

  const filteredClients = useMemo(() => {
    if (!clientSearch) return clientOptions
    const lower = clientSearch.toLowerCase()
    return clientOptions.filter(c => c.toLowerCase().includes(lower))
  }, [clientOptions, clientSearch])

  const handleDuplicate = useCallback(async (id: string) => {
    try {
      const dup = await networkDiagramsApi.duplicate(id)
      toast.success(`Created: ${dup.name}`)
      loadDiagrams()
    } catch {
      toast.error('Failed to duplicate')
    }
    setMenuOpenId(null)
  }, [loadDiagrams])

  const handleArchive = useCallback(async (id: string) => {
    try {
      await networkDiagramsApi.archive(id)
      toast.success('Diagram archived')
      loadDiagrams()
    } catch {
      toast.error('Failed to archive')
    }
    setMenuOpenId(null)
  }, [loadDiagrams])

  const handleImport = useCallback(async () => {
    const input = document.createElement('input')
    input.type = 'file'
    input.accept = '.json'
    input.onchange = async (e) => {
      const file = (e.target as HTMLInputElement).files?.[0]
      if (!file) return
      try {
        const text = await file.text()
        const data = JSON.parse(text) as DiagramImportData
        const result = await networkDiagramsApi.importJson(data)
        if (result.warnings.length > 0) {
          toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
        } else {
          toast.success('Diagram imported')
        }
        navigate(`/network-diagrams/${result.diagram.id}`)
      } catch {
        toast.error('Failed to import — check that the file is a valid diagram JSON')
      }
    }
    input.click()
  }, [navigate])

  const formatDate = (dateStr: string) => {
    return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
  }

  return (
    <div className="mx-auto max-w-6xl px-6 py-8">
      {/* Header */}
      <div className="mb-6 flex items-start justify-between">
        <div>
          <h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
          <p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
        </div>
        <div className="flex gap-2">
          <button
            onClick={handleImport}
            className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
          >
            <Upload size={14} />
            Import
          </button>
          <button
            onClick={() => navigate('/network-diagrams/new')}
            className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
          >
            <Plus size={14} />
            New Diagram
          </button>
        </div>
      </div>

      {/* Filters */}
      <div className="mb-6 flex gap-3">
        <div className="relative flex-1">
          <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
          <input
            type="text"
            placeholder="Search diagrams..."
            value={search}
            onChange={e => setSearch(e.target.value)}
            className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
          />
        </div>
        {/* Client combobox */}
        <div className="relative w-48">
          <button
            onClick={() => setClientDropdownOpen(prev => !prev)}
            className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
          >
            <span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
              {clientFilter || 'All clients'}
            </span>
            <span className="text-muted-foreground"></span>
          </button>
          {clientDropdownOpen && (
            <div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
              <div className="p-2">
                <input
                  type="text"
                  placeholder="Search clients..."
                  value={clientSearch}
                  onChange={e => setClientSearch(e.target.value)}
                  className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
                  autoFocus
                />
              </div>
              <div className="max-h-48 overflow-y-auto">
                <button
                  onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
                  className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
                >
                  All clients
                </button>
                {filteredClients.map(c => (
                  <button
                    key={c}
                    onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
                    className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
                  >
                    {c}
                  </button>
                ))}
              </div>
            </div>
          )}
        </div>
      </div>

      {/* Loading */}
      {loading && (
        <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
          {[1, 2, 3].map(i => (
            <div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
          ))}
        </div>
      )}

      {/* Empty state */}
      {!loading && diagrams.length === 0 && (
        <div className="flex flex-col items-center justify-center py-20">
          <Network size={48} className="mb-4 text-muted-foreground" />
          <h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
          <p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
          <button
            onClick={() => navigate('/network-diagrams/new')}
            className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
          >
            Create First Diagram
          </button>
        </div>
      )}

      {/* Diagram cards */}
      {!loading && diagrams.length > 0 && (
        <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
          {diagrams.map(d => (
            <div
              key={d.id}
              onClick={() => navigate(`/network-diagrams/${d.id}`)}
              className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
            >
              <div className="mb-2 flex items-start justify-between">
                <h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
                <button
                  onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
                  className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
                >
                  <MoreHorizontal size={14} />
                </button>
              </div>
              {d.client_name && (
                <span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
                  {d.client_name}
                </span>
              )}
              {d.description && (
                <p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
              )}
              <div className="flex items-center justify-between text-[10px] text-muted-foreground">
                <span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
                <span>{formatDate(d.created_at)}</span>
              </div>

              {/* Context menu */}
              {menuOpenId === d.id && (
                <div className="absolute right-2 top-10 z-50 w-36 rounded border border-default bg-card py-1 shadow-lg">
                  <button
                    onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
                    className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
                  >
                    Open
                  </button>
                  <button
                    onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
                    className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
                  >
                    Duplicate
                  </button>
                  <button
                    onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
                    className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
                  >
                    Archive
                  </button>
                </div>
              )}
            </div>
          ))}
        </div>
      )}
    </div>
  )
}
  • Step 2: Commit
git add frontend/src/pages/NetworkDiagrams/index.tsx
git commit -m "feat: add Network Diagrams list page with search, client filter, import"

Task 16: Navigation Integration (Sidebar + Router)

Files:

  • Modify: frontend/src/components/layout/Sidebar.tsx

  • Modify: frontend/src/router.tsx

  • Step 1: Add "Network Maps" to Sidebar rail groups

In frontend/src/components/layout/Sidebar.tsx, find the Flows rail group entry (the one with href: '/trees' and icon: GitBranch). Add { href: '/network-diagrams', label: 'Network Maps' } to its children array, after the Projects entry and before Solutions Library.

Also add '/network-diagrams' to the matchPaths array.

  • Step 2: Add "Network Maps" to Sidebar pinned sections

In the sections array under KNOWLEDGE, add a new NavEntry after the Flow Library entry:

{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },

Import Network from lucide-react (add to the existing import).

  • Step 3: Add routes to router.tsx

Add lazy imports at the top (after the existing lazy imports):

const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))

Add routes inside the ProtectedRoute/AppLayout children array:

{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
  • Step 4: Verify TypeScript compiles

Run:

cd frontend && npx tsc -b

Expected: No errors.

  • Step 5: Commit
git add frontend/src/components/layout/Sidebar.tsx frontend/src/router.tsx
git commit -m "feat: add Network Maps to sidebar navigation and router"

Task 17: Final Verification

  • Step 1: Run Alembic migrations
cd backend && source venv/bin/activate && alembic upgrade head

Expected: Migrations 073 and 074 apply successfully.

  • Step 2: Verify backend starts
cd backend && uvicorn app.main:app --reload &
sleep 3
curl -s http://localhost:8000/api/docs | grep -c "network-diagrams"
kill %1

Expected: At least 1 match (endpoints appear in OpenAPI docs).

  • Step 3: Verify frontend builds
cd frontend && npx tsc -b

Expected: No TypeScript errors.

  • Step 4: Verify full build (stricter)
cd frontend && npm run build

Expected: Build succeeds (or fails only on EACCES permission, per lesson #105 — TS compilation itself should succeed).

  • Step 5: Final commit with all files verified

If any files were missed or need tweaking, add and commit:

git add -A && git status

Review staged files — only network diagram related files should be included. Then commit if needed:

git commit -m "chore: final verification and cleanup for network diagrams feature"