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

{addError}

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

+ Select a device or connection to edit its properties +

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

Connection

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

Device Properties

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