# Network Diagrams Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a standalone network diagram builder with drag-and-drop device nodes, connection edges, database-driven device types, AI generation (replace + merge), and PNG/PDF/JSON export.

**Architecture:** Two new database tables (`device_types`, `network_diagrams`), two new FastAPI routers, an AI generation service using the existing `AnthropicProvider`, and a React Flow-based editor with toolbar, properties panel, and AI assist panel. Device types are database-driven with system defaults seeded on migration. Frontend rendering maps device type slugs to Lucide icons via a registry.

**Tech Stack:** FastAPI, SQLAlchemy 2.0, Alembic, React 19, @xyflow/react v12, Tailwind v4, Lucide React, Anthropic Claude API

**Design Spec:** `docs/superpowers/specs/2026-04-04-network-diagrams-design.md`

---

## File Map

### New Backend Files
| File | Responsibility |
|------|---------------|
| `backend/alembic/versions/073_add_device_types_table.py` | Migration: `device_types` table + seed system defaults |
| `backend/alembic/versions/074_add_network_diagrams_table.py` | Migration: `network_diagrams` table + indexes |
| `backend/app/models/device_type.py` | SQLAlchemy model for `device_types` |
| `backend/app/models/network_diagram.py` | SQLAlchemy model for `network_diagrams` |
| `backend/app/schemas/device_type.py` | Pydantic schemas for device type CRUD |
| `backend/app/schemas/network_diagram.py` | Pydantic schemas for diagrams + AI generation |
| `backend/app/api/endpoints/device_types.py` | Device types router (CRUD) |
| `backend/app/api/endpoints/network_diagrams.py` | Network diagrams router (CRUD + AI + export/import) |
| `backend/app/services/network_diagram_ai_service.py` | AI generation logic (system prompt, parsing, merge) |

### Modified Backend Files
| File | Change |
|------|--------|
| `backend/app/models/__init__.py` | Add `DeviceType`, `NetworkDiagram` imports |
| `backend/app/api/router.py` | Register `device_types` and `network_diagrams` routers |
| `backend/app/core/config.py` | Add `network_diagram_generate` to `ACTION_MODEL_MAP` |

### New Frontend Files
| File | Responsibility |
|------|---------------|
| `frontend/src/types/network-diagram.ts` | TypeScript interfaces for all network diagram types |
| `frontend/src/api/deviceTypes.ts` | API client for device types endpoints |
| `frontend/src/api/networkDiagrams.ts` | API client for network diagrams endpoints |
| `frontend/src/components/network/nodes/deviceRegistry.ts` | Slug → { icon, color } mapping + category fallbacks |
| `frontend/src/components/network/nodes/DeviceNode.tsx` | React Flow custom node component |
| `frontend/src/components/network/nodes/nodeTypes.ts` | Node type registry for React Flow |
| `frontend/src/components/network/edges/ConnectionEdge.tsx` | Custom edge with connection-type styling |
| `frontend/src/components/network/edges/edgeTypes.ts` | Edge type registry for React Flow |
| `frontend/src/components/network/panels/DeviceToolbar.tsx` | Left panel — categorized, searchable, draggable |
| `frontend/src/components/network/panels/PropertiesPanel.tsx` | Right panel — node/edge property editor |
| `frontend/src/components/network/panels/AIAssistPanel.tsx` | Bottom collapsible — AI generation |
| `frontend/src/components/network/NetworkCanvas.tsx` | React Flow wrapper |
| `frontend/src/components/network/DiagramHeader.tsx` | Top bar — name, save, export |
| `frontend/src/pages/NetworkDiagrams/index.tsx` | List/dashboard page |
| `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` | Full editor page |

### Modified Frontend Files
| File | Change |
|------|--------|
| `frontend/src/types/index.ts` | Export `network-diagram` types |
| `frontend/src/api/index.ts` | Export `deviceTypesApi` and `networkDiagramsApi` |
| `frontend/src/components/layout/Sidebar.tsx` | Add "Network Maps" nav item |
| `frontend/src/router.tsx` | Add 3 routes for network diagrams |

---

## Task 1: Device Types Migration + Model

**Files:**
- Create: `backend/alembic/versions/073_add_device_types_table.py`
- Create: `backend/app/models/device_type.py`
- Modify: `backend/app/models/__init__.py`

- [ ] **Step 1: Create the device_type SQLAlchemy model**

Create `backend/app/models/device_type.py`:

```python
"""Device type model for network diagrams."""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING

from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID

from app.core.database import Base

if TYPE_CHECKING:
    pass


class DeviceType(Base):
    """A device type for network diagram nodes (system or team-custom)."""
    __tablename__ = "device_types"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
    )
    slug: Mapped[str] = mapped_column(
        String(50), nullable=False,
        comment="Unique identifier used in diagram node data",
    )
    label: Mapped[str] = mapped_column(
        String(100), nullable=False,
        comment="Display name",
    )
    category: Mapped[str] = mapped_column(
        String(50), nullable=False,
        comment="network, compute, storage, cloud, endpoint, infrastructure, security",
    )
    is_system: Mapped[bool] = mapped_column(
        Boolean, nullable=False, default=False,
        comment="True for built-in types that cannot be deleted",
    )
    team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("teams.id", ondelete="CASCADE"),
        nullable=True,
        comment="NULL for system types, set for team-custom types",
    )
    sort_order: Mapped[int] = mapped_column(
        Integer, nullable=False, default=0,
        comment="Display order within category",
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )

    __table_args__ = (
        # PostgreSQL 15+ NULLS NOT DISTINCT so system types (team_id=NULL) are unique by slug
        # Implemented in migration as raw SQL since SQLAlchemy doesn't support this modifier
    )
```

- [ ] **Step 2: Create the Alembic migration**

Create `backend/alembic/versions/073_add_device_types_table.py`:

```python
"""Add device_types table with system seed data.

Revision ID: 073
Revises: 072
Create Date: 2026-04-04
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
import uuid


revision = "073"
down_revision = "072"
branch_labels = None
depends_on = None

# System device types to seed
SYSTEM_DEVICE_TYPES = [
    # Network
    ("router", "Router", "network", 0),
    ("switch", "Switch", "network", 1),
    ("firewall", "Firewall", "network", 2),
    ("access-point", "Access Point", "network", 3),
    ("load-balancer", "Load Balancer", "network", 4),
    # Compute
    ("server", "Server", "compute", 0),
    ("workstation", "Workstation", "compute", 1),
    ("vm", "Virtual Machine", "compute", 2),
    ("container", "Container", "compute", 3),
    # Storage
    ("nas", "NAS", "storage", 0),
    ("san", "SAN", "storage", 1),
    ("cloud-storage", "Cloud Storage", "storage", 2),
    # Cloud
    ("cloud", "Cloud", "cloud", 0),
    ("aws", "AWS", "cloud", 1),
    ("azure", "Azure", "cloud", 2),
    ("gcp", "Google Cloud", "cloud", 3),
    # Endpoint
    ("printer", "Printer", "endpoint", 0),
    ("phone", "Phone", "endpoint", 1),
    ("iot", "IoT Device", "endpoint", 2),
    ("camera", "Camera", "endpoint", 3),
    ("tablet", "Tablet", "endpoint", 4),
    ("laptop", "Laptop", "endpoint", 5),
    # Infrastructure
    ("ups", "UPS", "infrastructure", 0),
    ("pdu", "PDU", "infrastructure", 1),
    ("rack", "Rack", "infrastructure", 2),
    ("patch-panel", "Patch Panel", "infrastructure", 3),
    # Security
    ("nvr", "NVR", "security", 0),
    ("badge-reader", "Badge Reader", "security", 1),
]


def upgrade() -> None:
    op.create_table(
        "device_types",
        sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("slug", sa.String(50), nullable=False),
        sa.Column("label", sa.String(100), nullable=False),
        sa.Column("category", sa.String(50), nullable=False),
        sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
        sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True),
        sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
    )

    # NULLS NOT DISTINCT so (slug, NULL) is unique for system types
    op.execute(
        "ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team "
        "UNIQUE NULLS NOT DISTINCT (slug, team_id)"
    )
    op.create_index("idx_device_types_team", "device_types", ["team_id"])

    # Seed system device types
    device_types_table = sa.table(
        "device_types",
        sa.column("id", UUID(as_uuid=True)),
        sa.column("slug", sa.String),
        sa.column("label", sa.String),
        sa.column("category", sa.String),
        sa.column("is_system", sa.Boolean),
        sa.column("team_id", UUID(as_uuid=True)),
        sa.column("sort_order", sa.Integer),
    )

    op.bulk_insert(device_types_table, [
        {
            "id": uuid.uuid4(),
            "slug": slug,
            "label": label,
            "category": category,
            "is_system": True,
            "team_id": None,
            "sort_order": sort_order,
        }
        for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
    ])


def downgrade() -> None:
    op.drop_table("device_types")
```

- [ ] **Step 3: Register model in `__init__.py`**

Add to `backend/app/models/__init__.py`:

Import line (add after the last import):
```python
from .device_type import DeviceType
```

Add `"DeviceType"` to the `__all__` list.

- [ ] **Step 4: Verify migration applies cleanly**

Run:
```bash
cd backend && source venv/bin/activate && alembic upgrade head
```

Expected: Migration 073 applies without errors.

- [ ] **Step 5: Verify seed data**

Run:
```bash
docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SELECT count(*) FROM device_types WHERE is_system = true;"
```

Expected: `28` (the number of system device types).

- [ ] **Step 6: Commit**

```bash
git add backend/alembic/versions/073_add_device_types_table.py backend/app/models/device_type.py backend/app/models/__init__.py
git commit -m "feat: add device_types table with system seed data"
```

---

## Task 2: Network Diagrams Migration + Model

**Files:**
- Create: `backend/alembic/versions/074_add_network_diagrams_table.py`
- Create: `backend/app/models/network_diagram.py`
- Modify: `backend/app/models/__init__.py`

- [ ] **Step 1: Create the network_diagram SQLAlchemy model**

Create `backend/app/models/network_diagram.py`:

```python
"""Network diagram model."""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING

from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB

from app.core.database import Base

if TYPE_CHECKING:
    from app.models.user import User


class NetworkDiagram(Base):
    """A network topology diagram, team-scoped."""
    __tablename__ = "network_diagrams"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
    )
    team_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("teams.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
    )
    name: Mapped[str] = mapped_column(String(255), nullable=False)
    client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
    asset_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
    description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    nodes: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'")
    edges: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'")
    thumbnail_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
    is_archived: Mapped[bool] = mapped_column(
        Boolean, nullable=False, default=False,
    )
    created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("users.id"),
        nullable=True,
    )
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        default=lambda: datetime.now(timezone.utc),
        onupdate=lambda: datetime.now(timezone.utc),
    )

    # Relationships
    creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
```

- [ ] **Step 2: Create the Alembic migration**

Create `backend/alembic/versions/074_add_network_diagrams_table.py`:

```python
"""Add network_diagrams table.

Revision ID: 074
Revises: 073
Create Date: 2026-04-04
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB


revision = "074"
down_revision = "073"
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.create_table(
        "network_diagrams",
        sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=False),
        sa.Column("name", sa.String(255), nullable=False),
        sa.Column("client_name", sa.String(255), nullable=True),
        sa.Column("asset_name", sa.String(255), nullable=True),
        sa.Column("description", sa.Text(), nullable=True),
        sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
        sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
        sa.Column("thumbnail_url", sa.Text(), nullable=True),
        sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
        sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
        sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
    )

    op.create_index("idx_network_diagrams_team", "network_diagrams", ["team_id"])
    op.create_index("idx_network_diagrams_client", "network_diagrams", ["team_id", "client_name"])


def downgrade() -> None:
    op.drop_table("network_diagrams")
```

- [ ] **Step 3: Register model in `__init__.py`**

Add to `backend/app/models/__init__.py`:

Import line:
```python
from .network_diagram import NetworkDiagram
```

Add `"NetworkDiagram"` to the `__all__` list.

- [ ] **Step 4: Verify migration applies cleanly**

Run:
```bash
cd backend && alembic upgrade head
```

Expected: Migration 074 applies without errors.

- [ ] **Step 5: Commit**

```bash
git add backend/alembic/versions/074_add_network_diagrams_table.py backend/app/models/network_diagram.py backend/app/models/__init__.py
git commit -m "feat: add network_diagrams table"
```

---

## Task 3: Backend Pydantic Schemas

**Files:**
- Create: `backend/app/schemas/device_type.py`
- Create: `backend/app/schemas/network_diagram.py`

- [ ] **Step 1: Create device type schemas**

Create `backend/app/schemas/device_type.py`:

```python
"""Pydantic schemas for device types."""
from datetime import datetime
from typing import Optional
from uuid import UUID

from pydantic import BaseModel, Field


class DeviceTypeCreate(BaseModel):
    """Request to create a custom device type."""
    slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
    label: str = Field(min_length=1, max_length=100)
    category: str = Field(
        min_length=1, max_length=50,
        pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
    )
    sort_order: int = Field(default=0, ge=0)


class DeviceTypeUpdate(BaseModel):
    """Request to update a custom device type."""
    label: str | None = Field(default=None, min_length=1, max_length=100)
    category: str | None = Field(
        default=None, min_length=1, max_length=50,
        pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
    )
    sort_order: int | None = Field(default=None, ge=0)


class DeviceTypeResponse(BaseModel):
    """Device type as returned from the API."""
    id: UUID
    slug: str
    label: str
    category: str
    is_system: bool
    team_id: UUID | None = None
    sort_order: int
    created_at: datetime

    model_config = {"from_attributes": True}
```

- [ ] **Step 2: Create network diagram schemas**

Create `backend/app/schemas/network_diagram.py`:

```python
"""Pydantic schemas for network diagrams."""
from datetime import datetime
from typing import Optional
from uuid import UUID

from pydantic import BaseModel, Field


class DeviceProperties(BaseModel):
    """Properties of a device node."""
    hostname: str | None = None
    ip: str | None = None
    subnet: str | None = None
    vendor: str | None = None
    model: str | None = None
    role: str | None = None
    vlan: str | None = None
    notes: str | None = None
    status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")


class DiagramNode(BaseModel):
    """A node in a network diagram."""
    id: str
    type: str
    label: str
    position: dict  # { "x": number, "y": number }
    properties: DeviceProperties = Field(default_factory=DeviceProperties)


class DiagramEdge(BaseModel):
    """An edge (connection) in a network diagram."""
    id: str
    source: str
    target: str
    label: str | None = None
    connectionType: str = "ethernet"
    speed: str | None = None
    notes: str | None = None


class NetworkDiagramCreate(BaseModel):
    """Request to create a new diagram."""
    name: str = Field(min_length=1, max_length=255)
    client_name: str | None = None
    asset_name: str | None = None
    description: str | None = None
    nodes: list[DiagramNode] = Field(default_factory=list)
    edges: list[DiagramEdge] = Field(default_factory=list)


class NetworkDiagramUpdate(BaseModel):
    """Request to update a diagram (all fields optional)."""
    name: str | None = Field(default=None, min_length=1, max_length=255)
    client_name: str | None = None
    asset_name: str | None = None
    description: str | None = None
    nodes: list[DiagramNode] | None = None
    edges: list[DiagramEdge] | None = None


class NetworkDiagramResponse(BaseModel):
    """Full diagram as returned from the API."""
    id: UUID
    team_id: UUID
    name: str
    client_name: str | None = None
    asset_name: str | None = None
    description: str | None = None
    nodes: list[DiagramNode] = Field(default_factory=list)
    edges: list[DiagramEdge] = Field(default_factory=list)
    thumbnail_url: str | None = None
    is_archived: bool = False
    created_by: UUID | None = None
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}


class NetworkDiagramListItem(BaseModel):
    """Lightweight diagram for list views."""
    id: UUID
    name: str
    client_name: str | None = None
    description: str | None = None
    node_count: int = 0
    created_by: UUID | None = None
    created_at: datetime
    updated_at: datetime

    model_config = {"from_attributes": True}


class ExistingBounds(BaseModel):
    """Bounding box of existing nodes for merge mode."""
    minX: float
    maxX: float
    minY: float
    maxY: float


class AIGenerateRequest(BaseModel):
    """Request for AI diagram generation."""
    description: str = Field(min_length=1, max_length=5000)
    client_name: str | None = None
    mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
    existingBounds: ExistingBounds | None = None


class AIGenerateResponse(BaseModel):
    """AI-generated diagram data."""
    nodes: list[DiagramNode]
    edges: list[DiagramEdge]
    suggestedName: str | None = None
    notes: str | None = None


class DiagramImportRequest(BaseModel):
    """Imported JSON diagram data."""
    schemaVersion: int = Field(ge=1, le=1)
    name: str = Field(min_length=1, max_length=255)
    client_name: str | None = None
    description: str | None = None
    nodes: list[DiagramNode] = Field(default_factory=list)
    edges: list[DiagramEdge] = Field(default_factory=list)


class DiagramImportResponse(BaseModel):
    """Response after importing a diagram."""
    diagram: NetworkDiagramResponse
    warnings: list[str] = Field(default_factory=list)


class DiagramExportResponse(BaseModel):
    """JSON export format."""
    schemaVersion: int = 1
    name: str
    client_name: str | None = None
    description: str | None = None
    nodes: list[DiagramNode]
    edges: list[DiagramEdge]
    exportedAt: str
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/schemas/device_type.py backend/app/schemas/network_diagram.py
git commit -m "feat: add Pydantic schemas for device types and network diagrams"
```

---

## Task 4: Device Types Router

**Files:**
- Create: `backend/app/api/endpoints/device_types.py`
- Modify: `backend/app/api/router.py`

- [ ] **Step 1: Create the device types router**

Create `backend/app/api/endpoints/device_types.py`:

```python
"""Device types API endpoints."""
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.schemas.device_type import (
    DeviceTypeCreate,
    DeviceTypeUpdate,
    DeviceTypeResponse,
)

router = APIRouter(prefix="/device-types", tags=["device-types"])


@router.get("/", response_model=list[DeviceTypeResponse])
async def list_device_types(
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[DeviceTypeResponse]:
    """List all device types: system defaults + team custom."""
    stmt = (
        select(DeviceType)
        .where(
            or_(
                DeviceType.is_system.is_(True),
                DeviceType.team_id == current_user.team_id,
            )
        )
        .order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
    )
    result = await db.execute(stmt)
    rows = result.scalars().all()
    return [DeviceTypeResponse.model_validate(r) for r in rows]


@router.post("/", response_model=DeviceTypeResponse, status_code=201)
async def create_device_type(
    data: DeviceTypeCreate,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
    """Create a custom device type for the team."""
    # Check slug uniqueness within the team
    existing = await db.execute(
        select(DeviceType).where(
            DeviceType.slug == data.slug,
            DeviceType.team_id == current_user.team_id,
        )
    )
    if existing.scalar_one_or_none():
        raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your team")

    # Also check if slug conflicts with a system type
    system_existing = await db.execute(
        select(DeviceType).where(
            DeviceType.slug == data.slug,
            DeviceType.is_system.is_(True),
        )
    )
    if system_existing.scalar_one_or_none():
        raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")

    device_type = DeviceType(
        slug=data.slug,
        label=data.label,
        category=data.category,
        is_system=False,
        team_id=current_user.team_id,
        sort_order=data.sort_order,
    )
    db.add(device_type)
    await db.commit()
    await db.refresh(device_type)
    return DeviceTypeResponse.model_validate(device_type)


@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
async def update_device_type(
    device_type_id: UUID,
    data: DeviceTypeUpdate,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
    """Update a custom device type (team-owned only)."""
    device_type = await db.get(DeviceType, device_type_id)
    if not device_type:
        raise HTTPException(status_code=404, detail="Device type not found")
    if device_type.is_system:
        raise HTTPException(status_code=403, detail="Cannot modify system device types")
    if device_type.team_id != current_user.team_id:
        raise HTTPException(status_code=404, detail="Device type not found")

    update_data = data.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(device_type, field, value)

    await db.commit()
    await db.refresh(device_type)
    return DeviceTypeResponse.model_validate(device_type)


@router.delete("/{device_type_id}", status_code=204)
async def delete_device_type(
    device_type_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
    """Delete a custom device type (team-owned only)."""
    device_type = await db.get(DeviceType, device_type_id)
    if not device_type:
        raise HTTPException(status_code=404, detail="Device type not found")
    if device_type.is_system:
        raise HTTPException(status_code=403, detail="Cannot delete system device types")
    if device_type.team_id != current_user.team_id:
        raise HTTPException(status_code=404, detail="Device type not found")

    await db.delete(device_type)
    await db.commit()
```

- [ ] **Step 2: Register in router.py**

Add to `backend/app/api/router.py`:

Import line:
```python
from app.api.endpoints import device_types
```

Registration line (add after the last `include_router` call):
```python
api_router.include_router(device_types.router)
```

- [ ] **Step 3: Verify the endpoint starts**

Run:
```bash
cd backend && uvicorn app.main:app --reload &
sleep 3
curl -s http://localhost:8000/api/docs | head -5
kill %1
```

Expected: OpenAPI docs load, `/api/v1/device-types/` appears in the spec.

- [ ] **Step 4: Commit**

```bash
git add backend/app/api/endpoints/device_types.py backend/app/api/router.py
git commit -m "feat: add device types CRUD router"
```

---

## Task 5: Network Diagrams AI Service

**Files:**
- Create: `backend/app/services/network_diagram_ai_service.py`
- Modify: `backend/app/core/config.py`

- [ ] **Step 1: Add action to model tier routing**

In `backend/app/core/config.py`, add to the `ACTION_MODEL_MAP` dict:

```python
"network_diagram_generate": "standard",
```

- [ ] **Step 2: Create the AI generation service**

Create `backend/app/services/network_diagram_ai_service.py`:

```python
"""AI service for generating network diagrams from natural language."""
import json
import logging

from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.schemas.network_diagram import (
    AIGenerateRequest,
    AIGenerateResponse,
    DiagramNode,
    DiagramEdge,
    DeviceProperties,
)

logger = logging.getLogger(__name__)

SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers.
Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble.

Return this exact structure:
{{
  "nodes": [
    {{
      "id": "unique-string",
      "type": "device-type-slug",
      "label": "device label",
      "position": {{ "x": number, "y": number }},
      "properties": {{
        "hostname": "string or null",
        "ip": "string or null",
        "subnet": "string or null",
        "vendor": "string or null",
        "model": "string or null",
        "role": "string or null",
        "vlan": "string or null",
        "notes": "string or null",
        "status": "unknown"
      }}
    }}
  ],
  "edges": [
    {{
      "id": "unique-string",
      "source": "node-id",
      "target": "node-id",
      "label": "connection label or null",
      "connectionType": "ethernet|fiber|wifi|vpn|vlan|wan",
      "speed": "string or null",
      "notes": "string or null"
    }}
  ],
  "suggestedName": "short descriptive diagram name",
  "notes": "any important assumptions or missing info, or null"
}}

Available device type slugs: {available_slugs}

Position nodes thoughtfully in a logical network topology layout.
Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y.
Place WAN/internet at top, core network in middle, endpoints at bottom.
{merge_instructions}"""

MERGE_INSTRUCTIONS = """
IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices.
The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}.
Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100).
You may create edges that connect new nodes to existing nodes if the description implies a connection.
Use these existing node IDs for connections: {existing_node_ids}"""


async def generate_diagram(
    request: AIGenerateRequest,
    available_slugs: list[str],
    existing_node_ids: list[str] | None = None,
) -> AIGenerateResponse:
    """Generate a network diagram from a natural language description.

    Args:
        request: The generation request with description and mode.
        available_slugs: List of valid device type slugs.
        existing_node_ids: For merge mode, IDs of existing nodes on canvas.

    Returns:
        AIGenerateResponse with nodes, edges, suggestedName, notes.

    Raises:
        ValueError: If the AI response cannot be parsed as valid JSON.
    """
    merge_instructions = ""
    if request.mode == "merge" and request.existingBounds:
        b = request.existingBounds
        merge_instructions = MERGE_INSTRUCTIONS.format(
            minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY,
            existing_node_ids=", ".join(existing_node_ids or []),
        )

    system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
        available_slugs=", ".join(available_slugs),
        merge_instructions=merge_instructions,
    )

    model = settings.get_model_for_action("network_diagram_generate")
    provider = get_ai_provider(model)

    messages = [{"role": "user", "content": request.description}]

    response_text, input_tokens, output_tokens = await provider.generate_json(
        system_prompt=system_prompt,
        messages=messages,
        max_tokens=4096,
    )

    logger.info(
        "Network diagram AI generation: input_tokens=%d, output_tokens=%d",
        input_tokens, output_tokens,
    )

    # Parse the JSON response
    try:
        data = json.loads(response_text)
    except json.JSONDecodeError as e:
        logger.error("Failed to parse AI response as JSON: %s", e)
        raise ValueError("AI generated an invalid response, please try again")

    # Validate and build nodes
    nodes = []
    for raw_node in data.get("nodes", []):
        # Fall back to a generic type if the slug isn't in the available list
        node_type = raw_node.get("type", "server")
        if node_type not in available_slugs:
            logger.warning("Unknown device type '%s', falling back to 'server'", node_type)
            node_type = "server"

        nodes.append(DiagramNode(
            id=raw_node["id"],
            type=node_type,
            label=raw_node.get("label", node_type),
            position=raw_node.get("position", {"x": 0, "y": 0}),
            properties=DeviceProperties(**{
                k: v for k, v in raw_node.get("properties", {}).items()
                if k in DeviceProperties.model_fields
            }),
        ))

    # Build edges
    edges = []
    for raw_edge in data.get("edges", []):
        edges.append(DiagramEdge(
            id=raw_edge["id"],
            source=raw_edge["source"],
            target=raw_edge["target"],
            label=raw_edge.get("label"),
            connectionType=raw_edge.get("connectionType", "ethernet"),
            speed=raw_edge.get("speed"),
            notes=raw_edge.get("notes"),
        ))

    return AIGenerateResponse(
        nodes=nodes,
        edges=edges,
        suggestedName=data.get("suggestedName"),
        notes=data.get("notes"),
    )
```

- [ ] **Step 3: Commit**

```bash
git add backend/app/services/network_diagram_ai_service.py backend/app/core/config.py
git commit -m "feat: add AI generation service for network diagrams"
```

---

## Task 6: Network Diagrams Router

**Files:**
- Create: `backend/app/api/endpoints/network_diagrams.py`
- Modify: `backend/app/api/router.py`

- [ ] **Step 1: Create the network diagrams router**

Create `backend/app/api/endpoints/network_diagrams.py`:

```python
"""Network diagrams API endpoints."""
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.models.network_diagram import NetworkDiagram
from app.schemas.network_diagram import (
    NetworkDiagramCreate,
    NetworkDiagramUpdate,
    NetworkDiagramResponse,
    NetworkDiagramListItem,
    AIGenerateRequest,
    AIGenerateResponse,
    DiagramImportRequest,
    DiagramImportResponse,
    DiagramExportResponse,
    DiagramNode,
)
from app.services import network_diagram_ai_service

import logging
logger = logging.getLogger(__name__)

router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])


async def _get_diagram_or_404(
    diagram_id: UUID,
    team_id: UUID,
    db: AsyncSession,
) -> NetworkDiagram:
    """Fetch a diagram and verify team ownership."""
    diagram = await db.get(NetworkDiagram, diagram_id)
    if not diagram or diagram.team_id != team_id or diagram.is_archived:
        raise HTTPException(status_code=404, detail="Diagram not found")
    return diagram


def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
    """Convert ORM object to response schema."""
    return NetworkDiagramResponse.model_validate(diagram)


def _diagram_to_list_item(diagram: NetworkDiagram) -> NetworkDiagramListItem:
    """Convert ORM object to list item schema."""
    nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
    return NetworkDiagramListItem(
        id=diagram.id,
        name=diagram.name,
        client_name=diagram.client_name,
        description=diagram.description,
        node_count=len(nodes),
        created_by=diagram.created_by,
        created_at=diagram.created_at,
        updated_at=diagram.updated_at,
    )


@router.get("/clients", response_model=list[str])
async def list_client_names(
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
    """List distinct client_name values for the team (powers combobox filter)."""
    stmt = (
        select(NetworkDiagram.client_name)
        .where(
            NetworkDiagram.team_id == current_user.team_id,
            NetworkDiagram.is_archived.is_(False),
            NetworkDiagram.client_name.isnot(None),
            NetworkDiagram.client_name != "",
        )
        .distinct()
        .order_by(NetworkDiagram.client_name)
    )
    result = await db.execute(stmt)
    return [row[0] for row in result.all()]


@router.get("/", response_model=list[NetworkDiagramListItem])
async def list_diagrams(
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
    client_name: str | None = Query(default=None),
    search: str | None = Query(default=None),
) -> list[NetworkDiagramListItem]:
    """List all diagrams for the team (exclude archived)."""
    stmt = (
        select(NetworkDiagram)
        .where(
            NetworkDiagram.team_id == current_user.team_id,
            NetworkDiagram.is_archived.is_(False),
        )
        .order_by(NetworkDiagram.updated_at.desc())
    )

    if client_name:
        stmt = stmt.where(NetworkDiagram.client_name == client_name)

    if search:
        search_filter = f"%{search}%"
        stmt = stmt.where(
            or_(
                NetworkDiagram.name.ilike(search_filter),
                NetworkDiagram.client_name.ilike(search_filter),
            )
        )

    result = await db.execute(stmt)
    rows = result.scalars().all()
    return [_diagram_to_list_item(r) for r in rows]


@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
async def create_diagram(
    data: NetworkDiagramCreate,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
    """Create a new network diagram."""
    diagram = NetworkDiagram(
        team_id=current_user.team_id,
        name=data.name,
        client_name=data.client_name,
        asset_name=data.asset_name,
        description=data.description,
        nodes=[n.model_dump() for n in data.nodes],
        edges=[e.model_dump() for e in data.edges],
        created_by=current_user.id,
    )
    db.add(diagram)
    await db.commit()
    await db.refresh(diagram)
    return _diagram_to_response(diagram)


@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
async def get_diagram(
    diagram_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
    """Get a single diagram by ID."""
    diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
    return _diagram_to_response(diagram)


@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
async def update_diagram(
    diagram_id: UUID,
    data: NetworkDiagramUpdate,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
    """Update a diagram."""
    diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)

    update_data = data.model_dump(exclude_unset=True)
    if "nodes" in update_data and update_data["nodes"] is not None:
        update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
    if "edges" in update_data and update_data["edges"] is not None:
        update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]

    for field, value in update_data.items():
        setattr(diagram, field, value)

    diagram.updated_at = datetime.now(timezone.utc)
    await db.commit()
    await db.refresh(diagram)
    return _diagram_to_response(diagram)


@router.delete("/{diagram_id}", status_code=204)
async def archive_diagram(
    diagram_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
    """Soft delete (archive) a diagram."""
    diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
    diagram.is_archived = True
    diagram.updated_at = datetime.now(timezone.utc)
    await db.commit()


@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
async def duplicate_diagram(
    diagram_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
    """Duplicate a diagram."""
    source = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
    copy = NetworkDiagram(
        team_id=current_user.team_id,
        name=f"Copy of {source.name}",
        client_name=source.client_name,
        asset_name=source.asset_name,
        description=source.description,
        nodes=source.nodes,
        edges=source.edges,
        created_by=current_user.id,
    )
    db.add(copy)
    await db.commit()
    await db.refresh(copy)
    return _diagram_to_response(copy)


@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
async def export_diagram(
    diagram_id: UUID,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramExportResponse:
    """Export a diagram as JSON."""
    diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
    nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
    edges_raw = diagram.edges or []
    from app.schemas.network_diagram import DiagramEdge
    edges = [DiagramEdge(**e) for e in edges_raw]
    return DiagramExportResponse(
        schemaVersion=1,
        name=diagram.name,
        client_name=diagram.client_name,
        description=diagram.description,
        nodes=nodes,
        edges=edges,
        exportedAt=datetime.now(timezone.utc).isoformat(),
    )


@router.post("/import", response_model=DiagramImportResponse, status_code=201)
async def import_diagram(
    data: DiagramImportRequest,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramImportResponse:
    """Import a diagram from JSON."""
    # Check for unknown device type slugs
    available_stmt = select(DeviceType.slug).where(
        or_(
            DeviceType.is_system.is_(True),
            DeviceType.team_id == current_user.team_id,
        )
    )
    result = await db.execute(available_stmt)
    available_slugs = {row[0] for row in result.all()}

    warnings: list[str] = []
    for node in data.nodes:
        if node.type not in available_slugs:
            warnings.append(f"Unknown device type '{node.type}' — will render with default icon")

    diagram = NetworkDiagram(
        team_id=current_user.team_id,
        name=data.name,
        client_name=data.client_name,
        description=data.description,
        nodes=[n.model_dump() for n in data.nodes],
        edges=[e.model_dump() for e in data.edges],
        created_by=current_user.id,
    )
    db.add(diagram)
    await db.commit()
    await db.refresh(diagram)

    return DiagramImportResponse(
        diagram=_diagram_to_response(diagram),
        warnings=warnings,
    )


@router.post("/ai-generate", response_model=AIGenerateResponse)
async def ai_generate_diagram(
    data: AIGenerateRequest,
    db: Annotated[AsyncSession, Depends(get_db)],
    current_user: Annotated[User, Depends(get_current_active_user)],
) -> AIGenerateResponse:
    """Generate a network diagram from a natural language description."""
    # Get available device type slugs
    stmt = select(DeviceType.slug).where(
        or_(
            DeviceType.is_system.is_(True),
            DeviceType.team_id == current_user.team_id,
        )
    )
    result = await db.execute(stmt)
    available_slugs = [row[0] for row in result.all()]

    # Parse existing node IDs from request for merge mode
    existing_node_ids: list[str] | None = None
    if data.mode == "merge" and data.existingBounds:
        # Client should also send node IDs, but for now we just pass bounds
        existing_node_ids = []

    try:
        return await network_diagram_ai_service.generate_diagram(
            request=data,
            available_slugs=available_slugs,
            existing_node_ids=existing_node_ids,
        )
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e))
    except Exception:
        logger.exception("AI diagram generation failed")
        raise HTTPException(status_code=500, detail="Diagram generation failed")


```

- [ ] **Step 2: Register in router.py**

Add to `backend/app/api/router.py`:

Import line:
```python
from app.api.endpoints import network_diagrams
```

Registration line (add before `ai_sessions` to avoid `/{id}` path conflicts):
```python
api_router.include_router(network_diagrams.router)
```

- [ ] **Step 3: Verify backend starts cleanly**

Run:
```bash
cd backend && uvicorn app.main:app --reload &
sleep 3
curl -s http://localhost:8000/api/v1/network-diagrams/ -H "Authorization: Bearer invalid" | head -5
kill %1
```

Expected: Returns 401 (unauthorized), confirming the endpoint is registered.

- [ ] **Step 4: Commit**

```bash
git add backend/app/api/endpoints/network_diagrams.py backend/app/api/router.py
git commit -m "feat: add network diagrams CRUD + AI generate + export/import router"
```

---

## Task 7: Frontend TypeScript Types

**Files:**
- Create: `frontend/src/types/network-diagram.ts`
- Modify: `frontend/src/types/index.ts`

- [ ] **Step 1: Create TypeScript interfaces**

Create `frontend/src/types/network-diagram.ts`:

```typescript
export interface DeviceProperties {
  hostname: string | null
  ip: string | null
  subnet: string | null
  vendor: string | null
  model: string | null
  role: string | null
  vlan: string | null
  notes: string | null
  status: 'unknown' | 'online' | 'offline' | 'degraded'
}

export interface DiagramNode {
  id: string
  type: string
  label: string
  position: { x: number; y: number }
  properties: DeviceProperties
}

export interface DiagramEdge {
  id: string
  source: string
  target: string
  label: string | null
  connectionType: string
  speed: string | null
  notes: string | null
}

export interface DeviceTypeResponse {
  id: string
  slug: string
  label: string
  category: string
  is_system: boolean
  team_id: string | null
  sort_order: number
  created_at: string
}

export interface DeviceTypeCreate {
  slug: string
  label: string
  category: string
  sort_order?: number
}

export interface NetworkDiagramResponse {
  id: string
  team_id: string
  name: string
  client_name: string | null
  asset_name: string | null
  description: string | null
  nodes: DiagramNode[]
  edges: DiagramEdge[]
  thumbnail_url: string | null
  is_archived: boolean
  created_by: string | null
  created_at: string
  updated_at: string
}

export interface NetworkDiagramListItem {
  id: string
  name: string
  client_name: string | null
  description: string | null
  node_count: number
  created_by: string | null
  created_at: string
  updated_at: string
}

export interface NetworkDiagramCreate {
  name: string
  client_name?: string | null
  asset_name?: string | null
  description?: string | null
  nodes?: DiagramNode[]
  edges?: DiagramEdge[]
}

export interface NetworkDiagramUpdate {
  name?: string
  client_name?: string | null
  asset_name?: string | null
  description?: string | null
  nodes?: DiagramNode[]
  edges?: DiagramEdge[]
}

export interface AIGenerateRequest {
  description: string
  client_name?: string | null
  mode: 'replace' | 'merge'
  existingBounds?: { minX: number; maxX: number; minY: number; maxY: number } | null
}

export interface AIGenerateResponse {
  nodes: DiagramNode[]
  edges: DiagramEdge[]
  suggestedName: string | null
  notes: string | null
}

export interface DiagramImportData {
  schemaVersion: number
  name: string
  client_name?: string | null
  description?: string | null
  nodes: DiagramNode[]
  edges: DiagramEdge[]
}

export interface DiagramImportResponse {
  diagram: NetworkDiagramResponse
  warnings: string[]
}

export interface DiagramExportResponse {
  schemaVersion: number
  name: string
  client_name: string | null
  description: string | null
  nodes: DiagramNode[]
  edges: DiagramEdge[]
  exportedAt: string
}
```

- [ ] **Step 2: Export from types/index.ts**

Add to `frontend/src/types/index.ts`:

```typescript
export * from './network-diagram'
```

- [ ] **Step 3: Commit**

```bash
git add frontend/src/types/network-diagram.ts frontend/src/types/index.ts
git commit -m "feat: add TypeScript types for network diagrams"
```

---

## Task 8: Frontend API Clients

**Files:**
- Create: `frontend/src/api/deviceTypes.ts`
- Create: `frontend/src/api/networkDiagrams.ts`
- Modify: `frontend/src/api/index.ts`

- [ ] **Step 1: Create device types API client**

Create `frontend/src/api/deviceTypes.ts`:

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

export const deviceTypesApi = {
  async list(): Promise<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`:

```typescript
import apiClient from './client'
import type {
  NetworkDiagramResponse,
  NetworkDiagramListItem,
  NetworkDiagramCreate,
  NetworkDiagramUpdate,
  AIGenerateRequest,
  AIGenerateResponse,
  DiagramImportData,
  DiagramImportResponse,
  DiagramExportResponse,
} from '@/types'

export const networkDiagramsApi = {
  async list(params?: { client_name?: string; search?: string }): Promise<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`:

```typescript
export { deviceTypesApi } from './deviceTypes'
export { networkDiagramsApi } from './networkDiagrams'
```

- [ ] **Step 4: Commit**

```bash
git add frontend/src/api/deviceTypes.ts frontend/src/api/networkDiagrams.ts frontend/src/api/index.ts
git commit -m "feat: add frontend API clients for device types and network diagrams"
```

---

## Task 9: Device Registry + Custom Node + Edge

**Files:**
- Create: `frontend/src/components/network/nodes/deviceRegistry.ts`
- Create: `frontend/src/components/network/nodes/DeviceNode.tsx`
- Create: `frontend/src/components/network/nodes/nodeTypes.ts`
- Create: `frontend/src/components/network/edges/ConnectionEdge.tsx`
- Create: `frontend/src/components/network/edges/edgeTypes.ts`

- [ ] **Step 1: Create the device rendering registry**

Create `frontend/src/components/network/nodes/deviceRegistry.ts`:

```typescript
import type { LucideIcon } from 'lucide-react'
import {
  Network, Layers, Shield, Wifi, Server, Monitor, Box, Cloud,
  Printer, Smartphone, HardDrive, Scale, Database, CloudCog,
  Cpu, Tablet, Laptop, BatteryCharging, LayoutGrid, RectangleVertical,
  Cable, Camera, KeyRound,
} from 'lucide-react'

export interface DeviceRenderConfig {
  icon: LucideIcon
  color: string
}

/** Explicit icon mapping for system device types */
const SYSTEM_DEVICE_ICONS: Record<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`:

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

```typescript
import { DeviceNode } from './DeviceNode'

export const nodeTypes = {
  device: DeviceNode,
}
```

- [ ] **Step 4: Create the ConnectionEdge component**

Create `frontend/src/components/network/edges/ConnectionEdge.tsx`:

```tsx
import { memo } from 'react'
import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react'

interface ConnectionEdgeData {
  connectionType?: string
  speed?: string | null
  notes?: string | null
  [key: string]: unknown
}

const CONNECTION_STYLES: Record<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`:

```typescript
import { ConnectionEdge } from './ConnectionEdge'

export const edgeTypes = {
  connection: ConnectionEdge,
}
```

- [ ] **Step 6: Commit**

```bash
git add frontend/src/components/network/nodes/ frontend/src/components/network/edges/
git commit -m "feat: add device registry, DeviceNode, ConnectionEdge for React Flow"
```

---

## Task 10: DeviceToolbar Panel

**Files:**
- Create: `frontend/src/components/network/panels/DeviceToolbar.tsx`

- [ ] **Step 1: Create the DeviceToolbar component**

Create `frontend/src/components/network/panels/DeviceToolbar.tsx`:

```tsx
import { useState, useMemo, useCallback } from 'react'
import { Search, Plus, ChevronDown, ChevronRight, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
import { deviceTypesApi } from '@/api'

interface DeviceToolbarProps {
  deviceTypes: DeviceTypeResponse[]
  onDeviceTypesChange: () => void
}

export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) {
  const [search, setSearch] = useState('')
  const [collapsedCategories, setCollapsedCategories] = useState<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**

```bash
git add frontend/src/components/network/panels/DeviceToolbar.tsx
git commit -m "feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation"
```

---

## Task 11: PropertiesPanel

**Files:**
- Create: `frontend/src/components/network/panels/PropertiesPanel.tsx`

- [ ] **Step 1: Create the PropertiesPanel component**

Create `frontend/src/components/network/panels/PropertiesPanel.tsx`:

```tsx
import { useCallback } from 'react'
import { Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { DeviceProperties, DiagramEdge } from '@/types'
import type { Node, Edge } from '@xyflow/react'
import type { DeviceNodeData } from '../nodes/DeviceNode'

interface PropertiesPanelProps {
  selectedNode: Node | null
  selectedEdge: Edge | null
  onNodeUpdate: (nodeId: string, data: Partial<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**

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

```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**

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

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

```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**

```bash
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

```tsx
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**

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

```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**

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

```typescript
{ 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):

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

Add routes inside the `ProtectedRoute`/`AppLayout` children array:

```typescript
{ 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:
```bash
cd frontend && npx tsc -b
```

Expected: No errors.

- [ ] **Step 5: Commit**

```bash
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**

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

Expected: Migrations 073 and 074 apply successfully.

- [ ] **Step 2: Verify backend starts**

```bash
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**

```bash
cd frontend && npx tsc -b
```

Expected: No TypeScript errors.

- [ ] **Step 4: Verify full build (stricter)**

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

```bash
git add -A && git status
```

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

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