122 KiB
Network Diagrams Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a standalone network diagram builder with drag-and-drop device nodes, connection edges, database-driven device types, AI generation (replace + merge), and PNG/PDF/JSON export.
Architecture: Two new database tables (device_types, network_diagrams), two new FastAPI routers, an AI generation service using the existing AnthropicProvider, and a React Flow-based editor with toolbar, properties panel, and AI assist panel. Device types are database-driven with system defaults seeded on migration. Frontend rendering maps device type slugs to Lucide icons via a registry.
Tech Stack: FastAPI, SQLAlchemy 2.0, Alembic, React 19, @xyflow/react v12, Tailwind v4, Lucide React, Anthropic Claude API
Design Spec: docs/superpowers/specs/2026-04-04-network-diagrams-design.md
File Map
New Backend Files
| File | Responsibility |
|---|---|
backend/alembic/versions/073_add_device_types_table.py |
Migration: device_types table + seed system defaults |
backend/alembic/versions/074_add_network_diagrams_table.py |
Migration: network_diagrams table + indexes |
backend/app/models/device_type.py |
SQLAlchemy model for device_types |
backend/app/models/network_diagram.py |
SQLAlchemy model for network_diagrams |
backend/app/schemas/device_type.py |
Pydantic schemas for device type CRUD |
backend/app/schemas/network_diagram.py |
Pydantic schemas for diagrams + AI generation |
backend/app/api/endpoints/device_types.py |
Device types router (CRUD) |
backend/app/api/endpoints/network_diagrams.py |
Network diagrams router (CRUD + AI + export/import) |
backend/app/services/network_diagram_ai_service.py |
AI generation logic (system prompt, parsing, merge) |
Modified Backend Files
| File | Change |
|---|---|
backend/app/models/__init__.py |
Add DeviceType, NetworkDiagram imports |
backend/app/api/router.py |
Register device_types and network_diagrams routers |
backend/app/core/config.py |
Add network_diagram_generate to ACTION_MODEL_MAP |
New Frontend Files
| File | Responsibility |
|---|---|
frontend/src/types/network-diagram.ts |
TypeScript interfaces for all network diagram types |
frontend/src/api/deviceTypes.ts |
API client for device types endpoints |
frontend/src/api/networkDiagrams.ts |
API client for network diagrams endpoints |
frontend/src/components/network/nodes/deviceRegistry.ts |
Slug → { icon, color } mapping + category fallbacks |
frontend/src/components/network/nodes/DeviceNode.tsx |
React Flow custom node component |
frontend/src/components/network/nodes/nodeTypes.ts |
Node type registry for React Flow |
frontend/src/components/network/edges/ConnectionEdge.tsx |
Custom edge with connection-type styling |
frontend/src/components/network/edges/edgeTypes.ts |
Edge type registry for React Flow |
frontend/src/components/network/panels/DeviceToolbar.tsx |
Left panel — categorized, searchable, draggable |
frontend/src/components/network/panels/PropertiesPanel.tsx |
Right panel — node/edge property editor |
frontend/src/components/network/panels/AIAssistPanel.tsx |
Bottom collapsible — AI generation |
frontend/src/components/network/NetworkCanvas.tsx |
React Flow wrapper |
frontend/src/components/network/DiagramHeader.tsx |
Top bar — name, save, export |
frontend/src/pages/NetworkDiagrams/index.tsx |
List/dashboard page |
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx |
Full editor page |
Modified Frontend Files
| File | Change |
|---|---|
frontend/src/types/index.ts |
Export network-diagram types |
frontend/src/api/index.ts |
Export deviceTypesApi and networkDiagramsApi |
frontend/src/components/layout/Sidebar.tsx |
Add "Network Maps" nav item |
frontend/src/router.tsx |
Add 3 routes for network diagrams |
Task 1: Device Types Migration + Model
Files:
-
Create:
backend/alembic/versions/073_add_device_types_table.py -
Create:
backend/app/models/device_type.py -
Modify:
backend/app/models/__init__.py -
Step 1: Create the device_type SQLAlchemy model
Create backend/app/models/device_type.py:
"""Device type model for network diagrams."""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
pass
class DeviceType(Base):
"""A device type for network diagram nodes (system or team-custom)."""
__tablename__ = "device_types"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
slug: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="Unique identifier used in diagram node data",
)
label: Mapped[str] = mapped_column(
String(100), nullable=False,
comment="Display name",
)
category: Mapped[str] = mapped_column(
String(50), nullable=False,
comment="network, compute, storage, cloud, endpoint, infrastructure, security",
)
is_system: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
comment="True for built-in types that cannot be deleted",
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="CASCADE"),
nullable=True,
comment="NULL for system types, set for team-custom types",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, default=0,
comment="Display order within category",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
# PostgreSQL 15+ NULLS NOT DISTINCT so system types (team_id=NULL) are unique by slug
# Implemented in migration as raw SQL since SQLAlchemy doesn't support this modifier
)
- Step 2: Create the Alembic migration
Create backend/alembic/versions/073_add_device_types_table.py:
"""Add device_types table with system seed data.
Revision ID: 073
Revises: 072
Create Date: 2026-04-04
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
import uuid
revision = "073"
down_revision = "072"
branch_labels = None
depends_on = None
# System device types to seed
SYSTEM_DEVICE_TYPES = [
# Network
("router", "Router", "network", 0),
("switch", "Switch", "network", 1),
("firewall", "Firewall", "network", 2),
("access-point", "Access Point", "network", 3),
("load-balancer", "Load Balancer", "network", 4),
# Compute
("server", "Server", "compute", 0),
("workstation", "Workstation", "compute", 1),
("vm", "Virtual Machine", "compute", 2),
("container", "Container", "compute", 3),
# Storage
("nas", "NAS", "storage", 0),
("san", "SAN", "storage", 1),
("cloud-storage", "Cloud Storage", "storage", 2),
# Cloud
("cloud", "Cloud", "cloud", 0),
("aws", "AWS", "cloud", 1),
("azure", "Azure", "cloud", 2),
("gcp", "Google Cloud", "cloud", 3),
# Endpoint
("printer", "Printer", "endpoint", 0),
("phone", "Phone", "endpoint", 1),
("iot", "IoT Device", "endpoint", 2),
("camera", "Camera", "endpoint", 3),
("tablet", "Tablet", "endpoint", 4),
("laptop", "Laptop", "endpoint", 5),
# Infrastructure
("ups", "UPS", "infrastructure", 0),
("pdu", "PDU", "infrastructure", 1),
("rack", "Rack", "infrastructure", 2),
("patch-panel", "Patch Panel", "infrastructure", 3),
# Security
("nvr", "NVR", "security", 0),
("badge-reader", "Badge Reader", "security", 1),
]
def upgrade() -> None:
op.create_table(
"device_types",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("slug", sa.String(50), nullable=False),
sa.Column("label", sa.String(100), nullable=False),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
# NULLS NOT DISTINCT so (slug, NULL) is unique for system types
op.execute(
"ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team "
"UNIQUE NULLS NOT DISTINCT (slug, team_id)"
)
op.create_index("idx_device_types_team", "device_types", ["team_id"])
# Seed system device types
device_types_table = sa.table(
"device_types",
sa.column("id", UUID(as_uuid=True)),
sa.column("slug", sa.String),
sa.column("label", sa.String),
sa.column("category", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("team_id", UUID(as_uuid=True)),
sa.column("sort_order", sa.Integer),
)
op.bulk_insert(device_types_table, [
{
"id": uuid.uuid4(),
"slug": slug,
"label": label,
"category": category,
"is_system": True,
"team_id": None,
"sort_order": sort_order,
}
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
])
def downgrade() -> None:
op.drop_table("device_types")
- Step 3: Register model in
__init__.py
Add to backend/app/models/__init__.py:
Import line (add after the last import):
from .device_type import DeviceType
Add "DeviceType" to the __all__ list.
- Step 4: Verify migration applies cleanly
Run:
cd backend && source venv/bin/activate && alembic upgrade head
Expected: Migration 073 applies without errors.
- Step 5: Verify seed data
Run:
docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SELECT count(*) FROM device_types WHERE is_system = true;"
Expected: 28 (the number of system device types).
- Step 6: Commit
git add backend/alembic/versions/073_add_device_types_table.py backend/app/models/device_type.py backend/app/models/__init__.py
git commit -m "feat: add device_types table with system seed data"
Task 2: Network Diagrams Migration + Model
Files:
-
Create:
backend/alembic/versions/074_add_network_diagrams_table.py -
Create:
backend/app/models/network_diagram.py -
Modify:
backend/app/models/__init__.py -
Step 1: Create the network_diagram SQLAlchemy model
Create backend/app/models/network_diagram.py:
"""Network diagram model."""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class NetworkDiagram(Base):
"""A network topology diagram, team-scoped."""
__tablename__ = "network_diagrams"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
team_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
asset_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
nodes: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'")
edges: Mapped[list] = mapped_column(JSONB, nullable=False, server_default="'[]'")
thumbnail_url: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_archived: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by])
- Step 2: Create the Alembic migration
Create backend/alembic/versions/074_add_network_diagrams_table.py:
"""Add network_diagrams table.
Revision ID: 074
Revises: 073
Create Date: 2026-04-04
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "074"
down_revision = "073"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"network_diagrams",
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("client_name", sa.String(255), nullable=True),
sa.Column("asset_name", sa.String(255), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("nodes", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("edges", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("thumbnail_url", sa.Text(), nullable=True),
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()")),
)
op.create_index("idx_network_diagrams_team", "network_diagrams", ["team_id"])
op.create_index("idx_network_diagrams_client", "network_diagrams", ["team_id", "client_name"])
def downgrade() -> None:
op.drop_table("network_diagrams")
- Step 3: Register model in
__init__.py
Add to backend/app/models/__init__.py:
Import line:
from .network_diagram import NetworkDiagram
Add "NetworkDiagram" to the __all__ list.
- Step 4: Verify migration applies cleanly
Run:
cd backend && alembic upgrade head
Expected: Migration 074 applies without errors.
- Step 5: Commit
git add backend/alembic/versions/074_add_network_diagrams_table.py backend/app/models/network_diagram.py backend/app/models/__init__.py
git commit -m "feat: add network_diagrams table"
Task 3: Backend Pydantic Schemas
Files:
-
Create:
backend/app/schemas/device_type.py -
Create:
backend/app/schemas/network_diagram.py -
Step 1: Create device type schemas
Create backend/app/schemas/device_type.py:
"""Pydantic schemas for device types."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class DeviceTypeCreate(BaseModel):
"""Request to create a custom device type."""
slug: str = Field(min_length=1, max_length=50, pattern=r"^[a-z0-9\-]+$")
label: str = Field(min_length=1, max_length=100)
category: str = Field(
min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int = Field(default=0, ge=0)
class DeviceTypeUpdate(BaseModel):
"""Request to update a custom device type."""
label: str | None = Field(default=None, min_length=1, max_length=100)
category: str | None = Field(
default=None, min_length=1, max_length=50,
pattern=r"^(network|compute|storage|cloud|endpoint|infrastructure|security)$",
)
sort_order: int | None = Field(default=None, ge=0)
class DeviceTypeResponse(BaseModel):
"""Device type as returned from the API."""
id: UUID
slug: str
label: str
category: str
is_system: bool
team_id: UUID | None = None
sort_order: int
created_at: datetime
model_config = {"from_attributes": True}
- Step 2: Create network diagram schemas
Create backend/app/schemas/network_diagram.py:
"""Pydantic schemas for network diagrams."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class DeviceProperties(BaseModel):
"""Properties of a device node."""
hostname: str | None = None
ip: str | None = None
subnet: str | None = None
vendor: str | None = None
model: str | None = None
role: str | None = None
vlan: str | None = None
notes: str | None = None
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
class DiagramNode(BaseModel):
"""A node in a network diagram."""
id: str
type: str
label: str
position: dict # { "x": number, "y": number }
properties: DeviceProperties = Field(default_factory=DeviceProperties)
class DiagramEdge(BaseModel):
"""An edge (connection) in a network diagram."""
id: str
source: str
target: str
label: str | None = None
connectionType: str = "ethernet"
speed: str | None = None
notes: str | None = None
class NetworkDiagramCreate(BaseModel):
"""Request to create a new diagram."""
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class NetworkDiagramUpdate(BaseModel):
"""Request to update a diagram (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] | None = None
edges: list[DiagramEdge] | None = None
class NetworkDiagramResponse(BaseModel):
"""Full diagram as returned from the API."""
id: UUID
team_id: UUID
name: str
client_name: str | None = None
asset_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
thumbnail_url: str | None = None
is_archived: bool = False
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class NetworkDiagramListItem(BaseModel):
"""Lightweight diagram for list views."""
id: UUID
name: str
client_name: str | None = None
description: str | None = None
node_count: int = 0
created_by: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ExistingBounds(BaseModel):
"""Bounding box of existing nodes for merge mode."""
minX: float
maxX: float
minY: float
maxY: float
class AIGenerateRequest(BaseModel):
"""Request for AI diagram generation."""
description: str = Field(min_length=1, max_length=5000)
client_name: str | None = None
mode: str = Field(default="replace", pattern=r"^(replace|merge)$")
existingBounds: ExistingBounds | None = None
class AIGenerateResponse(BaseModel):
"""AI-generated diagram data."""
nodes: list[DiagramNode]
edges: list[DiagramEdge]
suggestedName: str | None = None
notes: str | None = None
class DiagramImportRequest(BaseModel):
"""Imported JSON diagram data."""
schemaVersion: int = Field(ge=1, le=1)
name: str = Field(min_length=1, max_length=255)
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode] = Field(default_factory=list)
edges: list[DiagramEdge] = Field(default_factory=list)
class DiagramImportResponse(BaseModel):
"""Response after importing a diagram."""
diagram: NetworkDiagramResponse
warnings: list[str] = Field(default_factory=list)
class DiagramExportResponse(BaseModel):
"""JSON export format."""
schemaVersion: int = 1
name: str
client_name: str | None = None
description: str | None = None
nodes: list[DiagramNode]
edges: list[DiagramEdge]
exportedAt: str
- Step 3: Commit
git add backend/app/schemas/device_type.py backend/app/schemas/network_diagram.py
git commit -m "feat: add Pydantic schemas for device types and network diagrams"
Task 4: Device Types Router
Files:
-
Create:
backend/app/api/endpoints/device_types.py -
Modify:
backend/app/api/router.py -
Step 1: Create the device types router
Create backend/app/api/endpoints/device_types.py:
"""Device types API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.schemas.device_type import (
DeviceTypeCreate,
DeviceTypeUpdate,
DeviceTypeResponse,
)
router = APIRouter(prefix="/device-types", tags=["device-types"])
@router.get("/", response_model=list[DeviceTypeResponse])
async def list_device_types(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[DeviceTypeResponse]:
"""List all device types: system defaults + team custom."""
stmt = (
select(DeviceType)
.where(
or_(
DeviceType.is_system.is_(True),
DeviceType.team_id == current_user.team_id,
)
)
.order_by(DeviceType.category, DeviceType.sort_order, DeviceType.label)
)
result = await db.execute(stmt)
rows = result.scalars().all()
return [DeviceTypeResponse.model_validate(r) for r in rows]
@router.post("/", response_model=DeviceTypeResponse, status_code=201)
async def create_device_type(
data: DeviceTypeCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
"""Create a custom device type for the team."""
# Check slug uniqueness within the team
existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.team_id == current_user.team_id,
)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your team")
# Also check if slug conflicts with a system type
system_existing = await db.execute(
select(DeviceType).where(
DeviceType.slug == data.slug,
DeviceType.is_system.is_(True),
)
)
if system_existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' conflicts with a system type")
device_type = DeviceType(
slug=data.slug,
label=data.label,
category=data.category,
is_system=False,
team_id=current_user.team_id,
sort_order=data.sort_order,
)
db.add(device_type)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.put("/{device_type_id}", response_model=DeviceTypeResponse)
async def update_device_type(
device_type_id: UUID,
data: DeviceTypeUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DeviceTypeResponse:
"""Update a custom device type (team-owned only)."""
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot modify system device types")
if device_type.team_id != current_user.team_id:
raise HTTPException(status_code=404, detail="Device type not found")
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(device_type, field, value)
await db.commit()
await db.refresh(device_type)
return DeviceTypeResponse.model_validate(device_type)
@router.delete("/{device_type_id}", status_code=204)
async def delete_device_type(
device_type_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
"""Delete a custom device type (team-owned only)."""
device_type = await db.get(DeviceType, device_type_id)
if not device_type:
raise HTTPException(status_code=404, detail="Device type not found")
if device_type.is_system:
raise HTTPException(status_code=403, detail="Cannot delete system device types")
if device_type.team_id != current_user.team_id:
raise HTTPException(status_code=404, detail="Device type not found")
await db.delete(device_type)
await db.commit()
- Step 2: Register in router.py
Add to backend/app/api/router.py:
Import line:
from app.api.endpoints import device_types
Registration line (add after the last include_router call):
api_router.include_router(device_types.router)
- Step 3: Verify the endpoint starts
Run:
cd backend && uvicorn app.main:app --reload &
sleep 3
curl -s http://localhost:8000/api/docs | head -5
kill %1
Expected: OpenAPI docs load, /api/v1/device-types/ appears in the spec.
- Step 4: Commit
git add backend/app/api/endpoints/device_types.py backend/app/api/router.py
git commit -m "feat: add device types CRUD router"
Task 5: Network Diagrams AI Service
Files:
-
Create:
backend/app/services/network_diagram_ai_service.py -
Modify:
backend/app/core/config.py -
Step 1: Add action to model tier routing
In backend/app/core/config.py, add to the ACTION_MODEL_MAP dict:
"network_diagram_generate": "standard",
- Step 2: Create the AI generation service
Create backend/app/services/network_diagram_ai_service.py:
"""AI service for generating network diagrams from natural language."""
import json
import logging
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.schemas.network_diagram import (
AIGenerateRequest,
AIGenerateResponse,
DiagramNode,
DiagramEdge,
DeviceProperties,
)
logger = logging.getLogger(__name__)
SYSTEM_PROMPT_TEMPLATE = """You are a network diagram generator for MSP engineers.
Given a plain English description of a network, you must return ONLY valid JSON with no markdown, no explanation, no preamble.
Return this exact structure:
{{
"nodes": [
{{
"id": "unique-string",
"type": "device-type-slug",
"label": "device label",
"position": {{ "x": number, "y": number }},
"properties": {{
"hostname": "string or null",
"ip": "string or null",
"subnet": "string or null",
"vendor": "string or null",
"model": "string or null",
"role": "string or null",
"vlan": "string or null",
"notes": "string or null",
"status": "unknown"
}}
}}
],
"edges": [
{{
"id": "unique-string",
"source": "node-id",
"target": "node-id",
"label": "connection label or null",
"connectionType": "ethernet|fiber|wifi|vpn|vlan|wan",
"speed": "string or null",
"notes": "string or null"
}}
],
"suggestedName": "short descriptive diagram name",
"notes": "any important assumptions or missing info, or null"
}}
Available device type slugs: {available_slugs}
Position nodes thoughtfully in a logical network topology layout.
Use x/y coordinates between 0 and 1200 for x, 0 and 800 for y.
Place WAN/internet at top, core network in middle, endpoints at bottom.
{merge_instructions}"""
MERGE_INSTRUCTIONS = """
IMPORTANT: You are ADDING devices to an existing diagram. Do NOT replace existing devices.
The existing diagram occupies this bounding box: minX={minX}, maxX={maxX}, minY={minY}, maxY={maxY}.
Place all new nodes OUTSIDE this bounding box — below (y > {maxY} + 100) or to the right (x > {maxX} + 100).
You may create edges that connect new nodes to existing nodes if the description implies a connection.
Use these existing node IDs for connections: {existing_node_ids}"""
async def generate_diagram(
request: AIGenerateRequest,
available_slugs: list[str],
existing_node_ids: list[str] | None = None,
) -> AIGenerateResponse:
"""Generate a network diagram from a natural language description.
Args:
request: The generation request with description and mode.
available_slugs: List of valid device type slugs.
existing_node_ids: For merge mode, IDs of existing nodes on canvas.
Returns:
AIGenerateResponse with nodes, edges, suggestedName, notes.
Raises:
ValueError: If the AI response cannot be parsed as valid JSON.
"""
merge_instructions = ""
if request.mode == "merge" and request.existingBounds:
b = request.existingBounds
merge_instructions = MERGE_INSTRUCTIONS.format(
minX=b.minX, maxX=b.maxX, minY=b.minY, maxY=b.maxY,
existing_node_ids=", ".join(existing_node_ids or []),
)
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(
available_slugs=", ".join(available_slugs),
merge_instructions=merge_instructions,
)
model = settings.get_model_for_action("network_diagram_generate")
provider = get_ai_provider(model)
messages = [{"role": "user", "content": request.description}]
response_text, input_tokens, output_tokens = await provider.generate_json(
system_prompt=system_prompt,
messages=messages,
max_tokens=4096,
)
logger.info(
"Network diagram AI generation: input_tokens=%d, output_tokens=%d",
input_tokens, output_tokens,
)
# Parse the JSON response
try:
data = json.loads(response_text)
except json.JSONDecodeError as e:
logger.error("Failed to parse AI response as JSON: %s", e)
raise ValueError("AI generated an invalid response, please try again")
# Validate and build nodes
nodes = []
for raw_node in data.get("nodes", []):
# Fall back to a generic type if the slug isn't in the available list
node_type = raw_node.get("type", "server")
if node_type not in available_slugs:
logger.warning("Unknown device type '%s', falling back to 'server'", node_type)
node_type = "server"
nodes.append(DiagramNode(
id=raw_node["id"],
type=node_type,
label=raw_node.get("label", node_type),
position=raw_node.get("position", {"x": 0, "y": 0}),
properties=DeviceProperties(**{
k: v for k, v in raw_node.get("properties", {}).items()
if k in DeviceProperties.model_fields
}),
))
# Build edges
edges = []
for raw_edge in data.get("edges", []):
edges.append(DiagramEdge(
id=raw_edge["id"],
source=raw_edge["source"],
target=raw_edge["target"],
label=raw_edge.get("label"),
connectionType=raw_edge.get("connectionType", "ethernet"),
speed=raw_edge.get("speed"),
notes=raw_edge.get("notes"),
))
return AIGenerateResponse(
nodes=nodes,
edges=edges,
suggestedName=data.get("suggestedName"),
notes=data.get("notes"),
)
- Step 3: Commit
git add backend/app/services/network_diagram_ai_service.py backend/app/core/config.py
git commit -m "feat: add AI generation service for network diagrams"
Task 6: Network Diagrams Router
Files:
-
Create:
backend/app/api/endpoints/network_diagrams.py -
Modify:
backend/app/api/router.py -
Step 1: Create the network diagrams router
Create backend/app/api/endpoints/network_diagrams.py:
"""Network diagrams API endpoints."""
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.api.deps import get_current_active_user
from app.models.user import User
from app.models.device_type import DeviceType
from app.models.network_diagram import NetworkDiagram
from app.schemas.network_diagram import (
NetworkDiagramCreate,
NetworkDiagramUpdate,
NetworkDiagramResponse,
NetworkDiagramListItem,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportRequest,
DiagramImportResponse,
DiagramExportResponse,
DiagramNode,
)
from app.services import network_diagram_ai_service
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
async def _get_diagram_or_404(
diagram_id: UUID,
team_id: UUID,
db: AsyncSession,
) -> NetworkDiagram:
"""Fetch a diagram and verify team ownership."""
diagram = await db.get(NetworkDiagram, diagram_id)
if not diagram or diagram.team_id != team_id or diagram.is_archived:
raise HTTPException(status_code=404, detail="Diagram not found")
return diagram
def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
"""Convert ORM object to response schema."""
return NetworkDiagramResponse.model_validate(diagram)
def _diagram_to_list_item(diagram: NetworkDiagram) -> NetworkDiagramListItem:
"""Convert ORM object to list item schema."""
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
return NetworkDiagramListItem(
id=diagram.id,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
node_count=len(nodes),
created_by=diagram.created_by,
created_at=diagram.created_at,
updated_at=diagram.updated_at,
)
@router.get("/clients", response_model=list[str])
async def list_client_names(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> list[str]:
"""List distinct client_name values for the team (powers combobox filter)."""
stmt = (
select(NetworkDiagram.client_name)
.where(
NetworkDiagram.team_id == current_user.team_id,
NetworkDiagram.is_archived.is_(False),
NetworkDiagram.client_name.isnot(None),
NetworkDiagram.client_name != "",
)
.distinct()
.order_by(NetworkDiagram.client_name)
)
result = await db.execute(stmt)
return [row[0] for row in result.all()]
@router.get("/", response_model=list[NetworkDiagramListItem])
async def list_diagrams(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
client_name: str | None = Query(default=None),
search: str | None = Query(default=None),
) -> list[NetworkDiagramListItem]:
"""List all diagrams for the team (exclude archived)."""
stmt = (
select(NetworkDiagram)
.where(
NetworkDiagram.team_id == current_user.team_id,
NetworkDiagram.is_archived.is_(False),
)
.order_by(NetworkDiagram.updated_at.desc())
)
if client_name:
stmt = stmt.where(NetworkDiagram.client_name == client_name)
if search:
search_filter = f"%{search}%"
stmt = stmt.where(
or_(
NetworkDiagram.name.ilike(search_filter),
NetworkDiagram.client_name.ilike(search_filter),
)
)
result = await db.execute(stmt)
rows = result.scalars().all()
return [_diagram_to_list_item(r) for r in rows]
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
async def create_diagram(
data: NetworkDiagramCreate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
"""Create a new network diagram."""
diagram = NetworkDiagram(
team_id=current_user.team_id,
name=data.name,
client_name=data.client_name,
asset_name=data.asset_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.get("/{diagram_id}", response_model=NetworkDiagramResponse)
async def get_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
"""Get a single diagram by ID."""
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
return _diagram_to_response(diagram)
@router.put("/{diagram_id}", response_model=NetworkDiagramResponse)
async def update_diagram(
diagram_id: UUID,
data: NetworkDiagramUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
"""Update a diagram."""
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
update_data = data.model_dump(exclude_unset=True)
if "nodes" in update_data and update_data["nodes"] is not None:
update_data["nodes"] = [n.model_dump() if hasattr(n, "model_dump") else n for n in update_data["nodes"]]
if "edges" in update_data and update_data["edges"] is not None:
update_data["edges"] = [e.model_dump() if hasattr(e, "model_dump") else e for e in update_data["edges"]]
for field, value in update_data.items():
setattr(diagram, field, value)
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(diagram)
return _diagram_to_response(diagram)
@router.delete("/{diagram_id}", status_code=204)
async def archive_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
"""Soft delete (archive) a diagram."""
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
diagram.is_archived = True
diagram.updated_at = datetime.now(timezone.utc)
await db.commit()
@router.post("/{diagram_id}/duplicate", response_model=NetworkDiagramResponse, status_code=201)
async def duplicate_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> NetworkDiagramResponse:
"""Duplicate a diagram."""
source = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
copy = NetworkDiagram(
team_id=current_user.team_id,
name=f"Copy of {source.name}",
client_name=source.client_name,
asset_name=source.asset_name,
description=source.description,
nodes=source.nodes,
edges=source.edges,
created_by=current_user.id,
)
db.add(copy)
await db.commit()
await db.refresh(copy)
return _diagram_to_response(copy)
@router.get("/{diagram_id}/export", response_model=DiagramExportResponse)
async def export_diagram(
diagram_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramExportResponse:
"""Export a diagram as JSON."""
diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db)
nodes = [DiagramNode(**n) for n in (diagram.nodes or [])]
edges_raw = diagram.edges or []
from app.schemas.network_diagram import DiagramEdge
edges = [DiagramEdge(**e) for e in edges_raw]
return DiagramExportResponse(
schemaVersion=1,
name=diagram.name,
client_name=diagram.client_name,
description=diagram.description,
nodes=nodes,
edges=edges,
exportedAt=datetime.now(timezone.utc).isoformat(),
)
@router.post("/import", response_model=DiagramImportResponse, status_code=201)
async def import_diagram(
data: DiagramImportRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> DiagramImportResponse:
"""Import a diagram from JSON."""
# Check for unknown device type slugs
available_stmt = select(DeviceType.slug).where(
or_(
DeviceType.is_system.is_(True),
DeviceType.team_id == current_user.team_id,
)
)
result = await db.execute(available_stmt)
available_slugs = {row[0] for row in result.all()}
warnings: list[str] = []
for node in data.nodes:
if node.type not in available_slugs:
warnings.append(f"Unknown device type '{node.type}' — will render with default icon")
diagram = NetworkDiagram(
team_id=current_user.team_id,
name=data.name,
client_name=data.client_name,
description=data.description,
nodes=[n.model_dump() for n in data.nodes],
edges=[e.model_dump() for e in data.edges],
created_by=current_user.id,
)
db.add(diagram)
await db.commit()
await db.refresh(diagram)
return DiagramImportResponse(
diagram=_diagram_to_response(diagram),
warnings=warnings,
)
@router.post("/ai-generate", response_model=AIGenerateResponse)
async def ai_generate_diagram(
data: AIGenerateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> AIGenerateResponse:
"""Generate a network diagram from a natural language description."""
# Get available device type slugs
stmt = select(DeviceType.slug).where(
or_(
DeviceType.is_system.is_(True),
DeviceType.team_id == current_user.team_id,
)
)
result = await db.execute(stmt)
available_slugs = [row[0] for row in result.all()]
# Parse existing node IDs from request for merge mode
existing_node_ids: list[str] | None = None
if data.mode == "merge" and data.existingBounds:
# Client should also send node IDs, but for now we just pass bounds
existing_node_ids = []
try:
return await network_diagram_ai_service.generate_diagram(
request=data,
available_slugs=available_slugs,
existing_node_ids=existing_node_ids,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception:
logger.exception("AI diagram generation failed")
raise HTTPException(status_code=500, detail="Diagram generation failed")
- Step 2: Register in router.py
Add to backend/app/api/router.py:
Import line:
from app.api.endpoints import network_diagrams
Registration line (add before ai_sessions to avoid /{id} path conflicts):
api_router.include_router(network_diagrams.router)
- Step 3: Verify backend starts cleanly
Run:
cd backend && uvicorn app.main:app --reload &
sleep 3
curl -s http://localhost:8000/api/v1/network-diagrams/ -H "Authorization: Bearer invalid" | head -5
kill %1
Expected: Returns 401 (unauthorized), confirming the endpoint is registered.
- Step 4: Commit
git add backend/app/api/endpoints/network_diagrams.py backend/app/api/router.py
git commit -m "feat: add network diagrams CRUD + AI generate + export/import router"
Task 7: Frontend TypeScript Types
Files:
-
Create:
frontend/src/types/network-diagram.ts -
Modify:
frontend/src/types/index.ts -
Step 1: Create TypeScript interfaces
Create frontend/src/types/network-diagram.ts:
export interface DeviceProperties {
hostname: string | null
ip: string | null
subnet: string | null
vendor: string | null
model: string | null
role: string | null
vlan: string | null
notes: string | null
status: 'unknown' | 'online' | 'offline' | 'degraded'
}
export interface DiagramNode {
id: string
type: string
label: string
position: { x: number; y: number }
properties: DeviceProperties
}
export interface DiagramEdge {
id: string
source: string
target: string
label: string | null
connectionType: string
speed: string | null
notes: string | null
}
export interface DeviceTypeResponse {
id: string
slug: string
label: string
category: string
is_system: boolean
team_id: string | null
sort_order: number
created_at: string
}
export interface DeviceTypeCreate {
slug: string
label: string
category: string
sort_order?: number
}
export interface NetworkDiagramResponse {
id: string
team_id: string
name: string
client_name: string | null
asset_name: string | null
description: string | null
nodes: DiagramNode[]
edges: DiagramEdge[]
thumbnail_url: string | null
is_archived: boolean
created_by: string | null
created_at: string
updated_at: string
}
export interface NetworkDiagramListItem {
id: string
name: string
client_name: string | null
description: string | null
node_count: number
created_by: string | null
created_at: string
updated_at: string
}
export interface NetworkDiagramCreate {
name: string
client_name?: string | null
asset_name?: string | null
description?: string | null
nodes?: DiagramNode[]
edges?: DiagramEdge[]
}
export interface NetworkDiagramUpdate {
name?: string
client_name?: string | null
asset_name?: string | null
description?: string | null
nodes?: DiagramNode[]
edges?: DiagramEdge[]
}
export interface AIGenerateRequest {
description: string
client_name?: string | null
mode: 'replace' | 'merge'
existingBounds?: { minX: number; maxX: number; minY: number; maxY: number } | null
}
export interface AIGenerateResponse {
nodes: DiagramNode[]
edges: DiagramEdge[]
suggestedName: string | null
notes: string | null
}
export interface DiagramImportData {
schemaVersion: number
name: string
client_name?: string | null
description?: string | null
nodes: DiagramNode[]
edges: DiagramEdge[]
}
export interface DiagramImportResponse {
diagram: NetworkDiagramResponse
warnings: string[]
}
export interface DiagramExportResponse {
schemaVersion: number
name: string
client_name: string | null
description: string | null
nodes: DiagramNode[]
edges: DiagramEdge[]
exportedAt: string
}
- Step 2: Export from types/index.ts
Add to frontend/src/types/index.ts:
export * from './network-diagram'
- Step 3: Commit
git add frontend/src/types/network-diagram.ts frontend/src/types/index.ts
git commit -m "feat: add TypeScript types for network diagrams"
Task 8: Frontend API Clients
Files:
-
Create:
frontend/src/api/deviceTypes.ts -
Create:
frontend/src/api/networkDiagrams.ts -
Modify:
frontend/src/api/index.ts -
Step 1: Create device types API client
Create frontend/src/api/deviceTypes.ts:
import apiClient from './client'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
export const deviceTypesApi = {
async list(): Promise<DeviceTypeResponse[]> {
const response = await apiClient.get<DeviceTypeResponse[]>('/device-types/')
return response.data
},
async create(data: DeviceTypeCreate): Promise<DeviceTypeResponse> {
const response = await apiClient.post<DeviceTypeResponse>('/device-types/', data)
return response.data
},
async update(id: string, data: Partial<DeviceTypeCreate>): Promise<DeviceTypeResponse> {
const response = await apiClient.put<DeviceTypeResponse>(`/device-types/${id}`, data)
return response.data
},
async remove(id: string): Promise<void> {
await apiClient.delete(`/device-types/${id}`)
},
}
- Step 2: Create network diagrams API client
Create frontend/src/api/networkDiagrams.ts:
import apiClient from './client'
import type {
NetworkDiagramResponse,
NetworkDiagramListItem,
NetworkDiagramCreate,
NetworkDiagramUpdate,
AIGenerateRequest,
AIGenerateResponse,
DiagramImportData,
DiagramImportResponse,
DiagramExportResponse,
} from '@/types'
export const networkDiagramsApi = {
async list(params?: { client_name?: string; search?: string }): Promise<NetworkDiagramListItem[]> {
const response = await apiClient.get<NetworkDiagramListItem[]>('/network-diagrams/', { params })
return response.data
},
async get(id: string): Promise<NetworkDiagramResponse> {
const response = await apiClient.get<NetworkDiagramResponse>(`/network-diagrams/${id}`)
return response.data
},
async create(data: NetworkDiagramCreate): Promise<NetworkDiagramResponse> {
const response = await apiClient.post<NetworkDiagramResponse>('/network-diagrams/', data)
return response.data
},
async update(id: string, data: NetworkDiagramUpdate): Promise<NetworkDiagramResponse> {
const response = await apiClient.put<NetworkDiagramResponse>(`/network-diagrams/${id}`, data)
return response.data
},
async archive(id: string): Promise<void> {
await apiClient.delete(`/network-diagrams/${id}`)
},
async duplicate(id: string): Promise<NetworkDiagramResponse> {
const response = await apiClient.post<NetworkDiagramResponse>(`/network-diagrams/${id}/duplicate`)
return response.data
},
async exportJson(id: string): Promise<DiagramExportResponse> {
const response = await apiClient.get<DiagramExportResponse>(`/network-diagrams/${id}/export`)
return response.data
},
async importJson(data: DiagramImportData): Promise<DiagramImportResponse> {
const response = await apiClient.post<DiagramImportResponse>('/network-diagrams/import', data)
return response.data
},
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
return response.data
},
async listClients(): Promise<string[]> {
const response = await apiClient.get<string[]>('/network-diagrams/clients')
return response.data
},
}
- Step 3: Export from api/index.ts
Add to frontend/src/api/index.ts:
export { deviceTypesApi } from './deviceTypes'
export { networkDiagramsApi } from './networkDiagrams'
- Step 4: Commit
git add frontend/src/api/deviceTypes.ts frontend/src/api/networkDiagrams.ts frontend/src/api/index.ts
git commit -m "feat: add frontend API clients for device types and network diagrams"
Task 9: Device Registry + Custom Node + Edge
Files:
-
Create:
frontend/src/components/network/nodes/deviceRegistry.ts -
Create:
frontend/src/components/network/nodes/DeviceNode.tsx -
Create:
frontend/src/components/network/nodes/nodeTypes.ts -
Create:
frontend/src/components/network/edges/ConnectionEdge.tsx -
Create:
frontend/src/components/network/edges/edgeTypes.ts -
Step 1: Create the device rendering registry
Create frontend/src/components/network/nodes/deviceRegistry.ts:
import type { LucideIcon } from 'lucide-react'
import {
Network, Layers, Shield, Wifi, Server, Monitor, Box, Cloud,
Printer, Smartphone, HardDrive, Scale, Database, CloudCog,
Cpu, Tablet, Laptop, BatteryCharging, LayoutGrid, RectangleVertical,
Cable, Camera, KeyRound,
} from 'lucide-react'
export interface DeviceRenderConfig {
icon: LucideIcon
color: string
}
/** Explicit icon mapping for system device types */
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
// Network
'router': { icon: Network, color: 'var(--color-accent)' },
'switch': { icon: Layers, color: 'var(--color-text-muted-foreground)' },
'firewall': { icon: Shield, color: 'var(--color-accent)' },
'access-point': { icon: Wifi, color: 'var(--color-text-muted-foreground)' },
'load-balancer': { icon: Scale, color: 'var(--color-text-muted-foreground)' },
// Compute
'server': { icon: Server, color: 'var(--color-text-muted-foreground)' },
'workstation': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
'vm': { icon: Box, color: 'var(--color-text-muted-foreground)' },
'container': { icon: Cpu, color: 'var(--color-text-muted-foreground)' },
// Storage
'nas': { icon: Database, color: 'var(--color-text-muted-foreground)' },
'san': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
'cloud-storage': { icon: CloudCog, color: 'var(--color-text-muted-foreground)' },
// Cloud
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'aws': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'azure': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'gcp': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
// Endpoint
'printer': { icon: Printer, color: 'var(--color-text-muted-foreground)' },
'phone': { icon: Smartphone, color: 'var(--color-text-muted-foreground)' },
'iot': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
'camera': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
'tablet': { icon: Tablet, color: 'var(--color-text-muted-foreground)' },
'laptop': { icon: Laptop, color: 'var(--color-text-muted-foreground)' },
// Infrastructure
'ups': { icon: BatteryCharging, color: 'var(--color-text-muted-foreground)' },
'pdu': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
'rack': { icon: RectangleVertical, color: 'var(--color-text-muted-foreground)' },
'patch-panel': { icon: Cable, color: 'var(--color-text-muted-foreground)' },
// Security
'nvr': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
'badge-reader': { icon: KeyRound, color: 'var(--color-text-muted-foreground)' },
}
/** Category-based fallback for custom device types */
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
'network': { icon: Network, color: 'var(--color-text-muted-foreground)' },
'compute': { icon: Server, color: 'var(--color-text-muted-foreground)' },
'storage': { icon: Database, color: 'var(--color-text-muted-foreground)' },
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'endpoint': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
'infrastructure': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
'security': { icon: Shield, color: 'var(--color-text-muted-foreground)' },
}
const FALLBACK: DeviceRenderConfig = { icon: Box, color: 'var(--color-text-muted-foreground)' }
/**
* Resolve a device type slug to its rendering config.
* Falls back to category default, then generic default.
*/
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
if (category && CATEGORY_DEFAULTS[category]) return CATEGORY_DEFAULTS[category]
return FALLBACK
}
/** All category labels for display */
export const CATEGORY_LABELS: Record<string, string> = {
'network': 'Network',
'compute': 'Compute',
'storage': 'Storage',
'cloud': 'Cloud',
'endpoint': 'Endpoints',
'infrastructure': 'Infrastructure',
'security': 'Security',
}
/** Category display order */
export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security']
- Step 2: Create the DeviceNode component
Create frontend/src/components/network/nodes/DeviceNode.tsx:
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { cn } from '@/lib/utils'
import { getDeviceRenderConfig } from './deviceRegistry'
import type { DeviceProperties } from '@/types'
export interface DeviceNodeData {
label: string
deviceType: string
category?: string
properties: DeviceProperties
[key: string]: unknown
}
const STATUS_COLORS: Record<string, string> = {
online: 'bg-emerald-400',
offline: 'bg-red-400',
degraded: 'bg-yellow-400',
unknown: 'bg-gray-500',
}
function DeviceNodeComponent({ data, selected }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = nodeData.properties?.status || 'unknown'
const ip = nodeData.properties?.ip
return (
<div
className={cn(
'relative flex min-w-[120px] flex-col items-center gap-1 rounded-lg border bg-card px-4 py-3',
'border-default transition-colors',
'group',
selected && 'border-accent',
)}
>
{/* Status dot */}
<div className={cn('absolute right-2 top-2 h-2 w-2 rounded-full', STATUS_COLORS[status])} />
{/* Icon */}
<Icon size={28} style={{ color }} />
{/* Label */}
<span className="text-center text-xs font-medium text-heading">{nodeData.label}</span>
{/* IP address */}
{ip && (
<span className="font-mono text-[10px] text-muted-foreground">{ip}</span>
)}
{/* Connection handles — visible on hover */}
<Handle type="target" position={Position.Top} className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
<Handle type="source" position={Position.Bottom} className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
<Handle type="target" position={Position.Left} id="left" className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
<Handle type="source" position={Position.Right} id="right" className="!h-2 !w-2 !border-default !bg-elevated opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
)
}
export const DeviceNode = memo(DeviceNodeComponent)
- Step 3: Create node type registry
Create frontend/src/components/network/nodes/nodeTypes.ts:
import { DeviceNode } from './DeviceNode'
export const nodeTypes = {
device: DeviceNode,
}
- Step 4: Create the ConnectionEdge component
Create frontend/src/components/network/edges/ConnectionEdge.tsx:
import { memo } from 'react'
import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react'
interface ConnectionEdgeData {
connectionType?: string
speed?: string | null
notes?: string | null
[key: string]: unknown
}
const CONNECTION_STYLES: Record<string, { stroke: string; strokeDasharray?: string; strokeWidth: number }> = {
ethernet: { stroke: '#60a5fa', strokeWidth: 2 },
fiber: { stroke: '#34d399', strokeWidth: 3 },
wifi: { stroke: '#a78bfa', strokeDasharray: '3,3', strokeWidth: 2 },
vpn: { stroke: '#eab308', strokeDasharray: '8,4', strokeWidth: 2 },
vlan: { stroke: '#848b9b', strokeWidth: 2 },
wan: { stroke: '#f87171', strokeDasharray: '12,4', strokeWidth: 2 },
}
const DEFAULT_STYLE = { stroke: '#848b9b', strokeWidth: 2 }
function ConnectionEdgeComponent(props: EdgeProps) {
const edgeData = props.data as ConnectionEdgeData | undefined
const connectionType = edgeData?.connectionType || 'ethernet'
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
return (
<>
<SmoothStepEdge
{...props}
style={{
...style,
...(props.selected ? { stroke: '#60a5fa', strokeWidth: style.strokeWidth + 1 } : {}),
}}
/>
{props.label && (
<EdgeLabelRenderer>
<div
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${(props.sourceX + props.targetX) / 2}px, ${(props.sourceY + props.targetY) / 2}px)`,
pointerEvents: 'all',
}}
>
{props.label}
</div>
</EdgeLabelRenderer>
)}
</>
)
}
export const ConnectionEdge = memo(ConnectionEdgeComponent)
- Step 5: Create edge type registry
Create frontend/src/components/network/edges/edgeTypes.ts:
import { ConnectionEdge } from './ConnectionEdge'
export const edgeTypes = {
connection: ConnectionEdge,
}
- Step 6: Commit
git add frontend/src/components/network/nodes/ frontend/src/components/network/edges/
git commit -m "feat: add device registry, DeviceNode, ConnectionEdge for React Flow"
Task 10: DeviceToolbar Panel
Files:
-
Create:
frontend/src/components/network/panels/DeviceToolbar.tsx -
Step 1: Create the DeviceToolbar component
Create frontend/src/components/network/panels/DeviceToolbar.tsx:
import { useState, useMemo, useCallback } from 'react'
import { Search, Plus, ChevronDown, ChevronRight, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry'
import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types'
import { deviceTypesApi } from '@/api'
interface DeviceToolbarProps {
deviceTypes: DeviceTypeResponse[]
onDeviceTypesChange: () => void
}
export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) {
const [search, setSearch] = useState('')
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
const [showAddForm, setShowAddForm] = useState(false)
const [newType, setNewType] = useState<DeviceTypeCreate>({ slug: '', label: '', category: 'network' })
const [addError, setAddError] = useState<string | null>(null)
const [addLoading, setAddLoading] = useState(false)
const filteredByCategory = useMemo(() => {
const lower = search.toLowerCase()
const filtered = search
? deviceTypes.filter(dt => dt.label.toLowerCase().includes(lower) || dt.slug.toLowerCase().includes(lower))
: deviceTypes
const grouped: Record<string, DeviceTypeResponse[]> = {}
for (const dt of filtered) {
if (!grouped[dt.category]) grouped[dt.category] = []
grouped[dt.category].push(dt)
}
return grouped
}, [deviceTypes, search])
const toggleCategory = useCallback((cat: string) => {
setCollapsedCategories(prev => {
const next = new Set(prev)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
return next
})
}, [])
const handleDragStart = useCallback((e: React.DragEvent, deviceType: DeviceTypeResponse) => {
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
slug: deviceType.slug,
label: deviceType.label,
category: deviceType.category,
}))
e.dataTransfer.effectAllowed = 'move'
}, [])
const handleAddType = useCallback(async () => {
if (!newType.slug || !newType.label) {
setAddError('Slug and label are required')
return
}
setAddLoading(true)
setAddError(null)
try {
await deviceTypesApi.create(newType)
setNewType({ slug: '', label: '', category: 'network' })
setShowAddForm(false)
onDeviceTypesChange()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to create device type'
setAddError(msg)
} finally {
setAddLoading(false)
}
}, [newType, onDeviceTypesChange])
return (
<div className="flex h-full w-[200px] flex-col border-r border-default bg-sidebar">
{/* Search */}
<div className="relative p-2">
<Search size={14} className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search devices..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded-md border border-default bg-input pl-8 pr-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
{/* Device list by category */}
<div className="flex-1 overflow-y-auto px-2 pb-2">
{CATEGORY_ORDER.map(cat => {
const items = filteredByCategory[cat]
if (!items?.length) return null
const collapsed = collapsedCategories.has(cat)
return (
<div key={cat} className="mb-1">
<button
onClick={() => toggleCategory(cat)}
className="flex w-full items-center gap-1 rounded px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground hover:text-primary"
>
{collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
{CATEGORY_LABELS[cat] || cat}
<span className="ml-auto text-[10px] font-normal">{items.length}</span>
</button>
{!collapsed && (
<div className="flex flex-col gap-0.5">
{items.map(dt => {
const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category)
return (
<div
key={dt.id}
draggable
onDragStart={e => handleDragStart(e, dt)}
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing"
>
<Icon size={14} style={{ color }} />
<span>{dt.label}</span>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
{/* Add custom type */}
<div className="border-t border-default p-2">
{!showAddForm ? (
<button
onClick={() => setShowAddForm(true)}
className="flex w-full items-center justify-center gap-1 rounded border border-default px-2 py-1.5 text-xs text-muted-foreground hover:border-hover hover:text-primary"
>
<Plus size={12} />
Custom Type
</button>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">New Type</span>
<button onClick={() => { setShowAddForm(false); setAddError(null) }} className="text-muted-foreground hover:text-primary">
<X size={12} />
</button>
</div>
<input
placeholder="slug (e.g. pacs-server)"
value={newType.slug}
onChange={e => setNewType(prev => ({ ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<input
placeholder="Label (e.g. PACS Server)"
value={newType.label}
onChange={e => setNewType(prev => ({ ...prev, label: e.target.value }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
<select
value={newType.category}
onChange={e => setNewType(prev => ({ ...prev, category: e.target.value }))}
className="rounded border border-default bg-input px-2 py-1 text-xs text-primary focus:border-accent focus:outline-none"
>
{CATEGORY_ORDER.map(c => (
<option key={c} value={c}>{CATEGORY_LABELS[c]}</option>
))}
</select>
{addError && <p className="text-[10px] text-red-400">{addError}</p>}
<button
onClick={handleAddType}
disabled={addLoading}
className="rounded bg-accent px-2 py-1 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
{addLoading ? 'Adding...' : 'Add Type'}
</button>
</div>
)}
</div>
</div>
)
}
- Step 2: Commit
git add frontend/src/components/network/panels/DeviceToolbar.tsx
git commit -m "feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation"
Task 11: PropertiesPanel
Files:
-
Create:
frontend/src/components/network/panels/PropertiesPanel.tsx -
Step 1: Create the PropertiesPanel component
Create frontend/src/components/network/panels/PropertiesPanel.tsx:
import { useCallback } from 'react'
import { Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { DeviceProperties, DiagramEdge } from '@/types'
import type { Node, Edge } from '@xyflow/react'
import type { DeviceNodeData } from '../nodes/DeviceNode'
interface PropertiesPanelProps {
selectedNode: Node | null
selectedEdge: Edge | null
onNodeUpdate: (nodeId: string, data: Partial<DeviceNodeData>) => void
onEdgeUpdate: (edgeId: string, data: Partial<DiagramEdge>) => void
onDeleteNode: (nodeId: string) => void
onDeleteEdge: (edgeId: string) => void
}
const STATUS_OPTIONS = ['unknown', 'online', 'offline', 'degraded'] as const
const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const
function FieldLabel({ children }: { children: React.ReactNode }) {
return (
<label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</label>
)
}
function FieldInput({ value, onChange, placeholder, mono }: {
value: string
onChange: (val: string) => void
placeholder?: string
mono?: boolean
}) {
return (
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none',
mono && 'font-mono',
)}
/>
)
}
export function PropertiesPanel({
selectedNode,
selectedEdge,
onNodeUpdate,
onEdgeUpdate,
onDeleteNode,
onDeleteEdge,
}: PropertiesPanelProps) {
const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => {
if (!selectedNode) return
const nodeData = selectedNode.data as unknown as DeviceNodeData
onNodeUpdate(selectedNode.id, {
properties: { ...nodeData.properties, [field]: value },
} as Partial<DeviceNodeData>)
}, [selectedNode, onNodeUpdate])
const handleLabelChange = useCallback((value: string) => {
if (!selectedNode) return
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
}, [selectedNode, onNodeUpdate])
// Nothing selected
if (!selectedNode && !selectedEdge) {
return (
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
<p className="text-center text-xs text-muted-foreground">
Select a device or connection to edit its properties
</p>
</div>
)
}
// Edge selected
if (selectedEdge) {
const edgeData = (selectedEdge.data || {}) as Record<string, unknown>
const connectionType = (edgeData.connectionType as string) || 'ethernet'
const isCustomType = !CONNECTION_TYPE_OPTIONS.includes(connectionType as typeof CONNECTION_TYPE_OPTIONS[number])
return (
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
<div className="border-b border-default px-3 py-2">
<h3 className="text-xs font-semibold text-heading">Connection</h3>
</div>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
<div className="flex flex-col gap-1">
<FieldLabel>Label</FieldLabel>
<FieldInput
value={(selectedEdge.label as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { label: val || null })}
placeholder="Connection label"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Connection Type</FieldLabel>
<select
value={isCustomType ? '__custom__' : connectionType}
onChange={e => {
const val = e.target.value
if (val !== '__custom__') {
onEdgeUpdate(selectedEdge.id, { connectionType: val })
}
}}
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
>
{CONNECTION_TYPE_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
<option value="__custom__">Custom...</option>
</select>
{isCustomType && (
<FieldInput
value={connectionType}
onChange={val => onEdgeUpdate(selectedEdge.id, { connectionType: val })}
placeholder="Custom type name"
/>
)}
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Speed</FieldLabel>
<FieldInput
value={(edgeData.speed as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { speed: val || null })}
placeholder="e.g. 1 Gbps"
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Notes</FieldLabel>
<FieldInput
value={(edgeData.notes as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
placeholder="Port info, cable type..."
mono
/>
</div>
</div>
<div className="border-t border-default p-3">
<button
onClick={() => onDeleteEdge(selectedEdge.id)}
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>
<Trash2 size={12} />
Delete Connection
</button>
</div>
</div>
)
}
// Node selected
const nodeData = selectedNode!.data as unknown as DeviceNodeData
const props = nodeData.properties || {} as DeviceProperties
return (
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
<div className="border-b border-default px-3 py-2">
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
</div>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
<div className="flex flex-col gap-1">
<FieldLabel>Label</FieldLabel>
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Hostname</FieldLabel>
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>IP Address</FieldLabel>
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Subnet</FieldLabel>
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Vendor</FieldLabel>
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Model</FieldLabel>
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Role</FieldLabel>
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>VLAN</FieldLabel>
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Status</FieldLabel>
<select
value={props.status || 'unknown'}
onChange={e => handlePropertyChange('status', e.target.value)}
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
>
{STATUS_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Notes</FieldLabel>
<textarea
value={props.notes || ''}
onChange={e => handlePropertyChange('notes', e.target.value)}
placeholder="Additional notes..."
rows={3}
className="w-full resize-none rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
</div>
<div className="border-t border-default p-3">
<button
onClick={() => onDeleteNode(selectedNode!.id)}
className="flex w-full items-center justify-center gap-1.5 rounded border border-red-500/30 px-2 py-1.5 text-xs text-red-400 hover:bg-red-500/10"
>
<Trash2 size={12} />
Delete Device
</button>
</div>
</div>
)
}
- Step 2: Commit
git add frontend/src/components/network/panels/PropertiesPanel.tsx
git commit -m "feat: add PropertiesPanel for node and edge property editing"
Task 12: AIAssistPanel
Files:
-
Create:
frontend/src/components/network/panels/AIAssistPanel.tsx -
Step 1: Create the AIAssistPanel component
Create frontend/src/components/network/panels/AIAssistPanel.tsx:
import { useState, useCallback } from 'react'
import { Sparkles, ChevronUp, ChevronDown, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import type { AIGenerateResponse } from '@/types'
interface AIAssistPanelProps {
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
getExistingBounds: () => { minX: number; maxX: number; minY: number; maxY: number } | null
hasNodes: boolean
}
export function AIAssistPanel({ onGenerate, getExistingBounds, hasNodes }: AIAssistPanelProps) {
const [expanded, setExpanded] = useState(false)
const [description, setDescription] = useState('')
const [mode, setMode] = useState<'replace' | 'merge'>('replace')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleGenerate = useCallback(async () => {
if (!description.trim()) return
setLoading(true)
setError(null)
try {
const result = await networkDiagramsApi.aiGenerate({
description: description.trim(),
mode,
existingBounds: mode === 'merge' ? getExistingBounds() : null,
})
onGenerate(result, mode)
setDescription('')
setExpanded(false)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Generation failed. Please try again.'
setError(msg)
} finally {
setLoading(false)
}
}, [description, mode, onGenerate, getExistingBounds])
if (!expanded) {
return (
<div className="border-t border-default bg-card">
<button
onClick={() => setExpanded(true)}
className="flex w-full items-center justify-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-primary"
>
<Sparkles size={14} />
AI Generate
<ChevronUp size={14} />
</button>
</div>
)
}
return (
<div className="border-t border-default bg-card">
<div className="flex items-center justify-between border-b border-default px-4 py-2">
<div className="flex items-center gap-2 text-xs font-medium text-heading">
<Sparkles size={14} />
AI Generate
</div>
<button onClick={() => setExpanded(false)} className="text-muted-foreground hover:text-primary">
<ChevronDown size={14} />
</button>
</div>
<div className="flex flex-col gap-3 p-4">
{/* Mode toggle */}
<div className="flex gap-2">
<button
onClick={() => setMode('replace')}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
mode === 'replace'
? 'bg-accent text-white'
: 'border border-default text-muted-foreground hover:text-primary',
)}
>
Generate New
</button>
<button
onClick={() => setMode('merge')}
className={cn(
'rounded px-3 py-1 text-xs font-medium transition-colors',
mode === 'merge'
? 'bg-accent text-white'
: 'border border-default text-muted-foreground hover:text-primary',
)}
>
Add to Diagram
</button>
</div>
{/* Warning for replace mode */}
{mode === 'replace' && hasNodes && (
<div className="flex items-start gap-2 rounded border border-yellow-500/30 bg-yellow-500/5 px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-yellow-400" />
<p className="text-[11px] text-yellow-400">
This will replace your current diagram. Save first if needed.
</p>
</div>
)}
{/* Description textarea */}
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Describe the network you want to create... e.g. 'Small office with a firewall, core switch, 3 access points, and a file server'"
rows={3}
disabled={loading}
className="w-full resize-none rounded border border-default bg-input px-3 py-2 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
/>
{/* Error */}
{error && <p className="text-[11px] text-red-400">{error}</p>}
{/* Generate button / Loading state */}
{loading ? (
<div className="flex items-center justify-center gap-2 py-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
<span className="text-xs text-muted-foreground">Generating your network diagram...</span>
</div>
) : (
<button
onClick={handleGenerate}
disabled={!description.trim()}
className="rounded bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
Generate
</button>
)}
</div>
</div>
)
}
- Step 2: Commit
git add frontend/src/components/network/panels/AIAssistPanel.tsx
git commit -m "feat: add AIAssistPanel with replace and merge modes"
Task 13: NetworkCanvas + DiagramHeader
Files:
-
Create:
frontend/src/components/network/NetworkCanvas.tsx -
Create:
frontend/src/components/network/DiagramHeader.tsx -
Step 1: Create NetworkCanvas
Create frontend/src/components/network/NetworkCanvas.tsx:
import { useCallback } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
BackgroundVariant,
type OnConnect,
type OnNodesChange,
type OnEdgesChange,
type Node,
type Edge,
addEdge,
type Connection,
} from '@xyflow/react'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import type { DeviceNodeData } from './nodes/DeviceNode'
import type { DeviceProperties, DeviceTypeResponse } from '@/types'
interface NetworkCanvasProps {
nodes: Node[]
edges: Edge[]
onNodesChange: OnNodesChange
onEdgesChange: OnEdgesChange
onConnect: OnConnect
onNodeSelect: (node: Node | null) => void
onEdgeSelect: (edge: Edge | null) => void
onDrop: (event: React.DragEvent) => void
onDragOver: (event: React.DragEvent) => void
}
export function NetworkCanvas({
nodes,
edges,
onNodesChange,
onEdgesChange,
onConnect,
onNodeSelect,
onEdgeSelect,
onDrop,
onDragOver,
}: NetworkCanvasProps) {
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length === 1) {
onNodeSelect(selectedNodes[0])
onEdgeSelect(null)
} else if (selectedEdges.length === 1) {
onEdgeSelect(selectedEdges[0])
onNodeSelect(null)
} else {
onNodeSelect(null)
onEdgeSelect(null)
}
}, [onNodeSelect, onEdgeSelect])
const handlePaneClick = useCallback(() => {
onNodeSelect(null)
onEdgeSelect(null)
}, [onNodeSelect, onEdgeSelect])
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onSelectionChange={handleSelectionChange}
onPaneClick={handlePaneClick}
onDrop={onDrop}
onDragOver={onDragOver}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'connection' }}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode="Shift"
fitView
className="bg-page"
>
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
<MiniMap
nodeColor="var(--color-bg-elevated)"
maskColor="rgba(0,0,0,0.5)"
className="!border-default !bg-card"
position="bottom-right"
/>
</ReactFlow>
)
}
- Step 2: Create DiagramHeader
Create frontend/src/components/network/DiagramHeader.tsx:
import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DiagramHeaderProps {
name: string
clientName: string | null
isSaving: boolean
lastSavedAt: Date | null
diagramId: string | null
onNameChange: (name: string) => void
onSave: () => void
onExportPng: () => void
onExportPdf: () => void
onExportJson: () => void
}
export function DiagramHeader({
name,
clientName,
isSaving,
lastSavedAt,
diagramId,
onNameChange,
onSave,
onExportPng,
onExportPdf,
onExportJson,
}: DiagramHeaderProps) {
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(name)
const [showExportMenu, setShowExportMenu] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const exportMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editing])
useEffect(() => {
setEditValue(name)
}, [name])
// Close export menu on outside click
useEffect(() => {
if (!showExportMenu) return
const handleClick = (e: MouseEvent) => {
if (exportMenuRef.current && !exportMenuRef.current.contains(e.target as HTMLElement)) {
setShowExportMenu(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [showExportMenu])
const handleConfirmName = useCallback(() => {
setEditing(false)
if (editValue.trim() && editValue !== name) {
onNameChange(editValue.trim())
} else {
setEditValue(name)
}
}, [editValue, name, onNameChange])
const formatLastSaved = () => {
if (!lastSavedAt) return null
const diff = Date.now() - lastSavedAt.getTime()
if (diff < 60_000) return 'Saved just now'
const mins = Math.floor(diff / 60_000)
return `Saved ${mins}m ago`
}
return (
<div className="flex h-14 items-center gap-3 border-b border-default bg-card px-4">
{/* Back button */}
<button
onClick={() => navigate('/network-diagrams')}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary"
>
<ChevronLeft size={16} />
Network Maps
</button>
<div className="mx-2 h-5 w-px bg-border-default" />
{/* Editable name */}
{editing ? (
<input
ref={inputRef}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onBlur={handleConfirmName}
onKeyDown={e => { if (e.key === 'Enter') handleConfirmName(); if (e.key === 'Escape') { setEditing(false); setEditValue(name) } }}
className="rounded border border-accent bg-input px-2 py-1 text-sm font-heading font-semibold text-heading focus:outline-none"
/>
) : (
<button
onClick={() => setEditing(true)}
className="text-sm font-heading font-semibold text-heading hover:text-accent"
>
{name || 'Untitled Diagram'}
</button>
)}
{/* Client badge */}
{clientName && (
<span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
{clientName}
</span>
)}
<div className="flex-1" />
{/* Last saved */}
{lastSavedAt && (
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
)}
{/* Save */}
<button
onClick={onSave}
disabled={isSaving}
className="flex items-center gap-1.5 rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
{/* Export dropdown */}
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu(prev => !prev)}
className="flex items-center gap-1.5 rounded border border-default px-3 py-1.5 text-xs text-primary hover:border-hover"
>
<Download size={14} />
Export
</button>
{showExportMenu && (
<div className="absolute right-0 top-full z-50 mt-1 w-40 rounded border border-default bg-card py-1 shadow-lg">
<button
onClick={() => { onExportPng(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<Image size={12} /> Export PNG
</button>
<button
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileText size={12} /> Export PDF
</button>
{diagramId && (
<button
onClick={() => { onExportJson(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileJson size={12} /> Export JSON
</button>
)}
</div>
)}
</div>
</div>
)
}
- Step 3: Commit
git add frontend/src/components/network/NetworkCanvas.tsx frontend/src/components/network/DiagramHeader.tsx
git commit -m "feat: add NetworkCanvas wrapper and DiagramHeader components"
Task 14: DiagramEditor Page
Files:
-
Create:
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx -
Step 1: Create the DiagramEditor page
Create frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx. This is the largest component — it assembles all panels, manages state, handles auto-save, and implements drag-drop, export, and AI generation.
The file is large. Key sections to implement:
- State:
useNodesState,useEdgesState,diagramMeta,isDirty,lastSavedAt,selectedNode,selectedEdge,deviceTypes - Load: Fetch diagram by
idparam on mount, or start in "new" mode - Save:
POSTfor new,PUTfor existing. Set dirty flag on any change. - Auto-save:
setInterval30s, only if dirty and hasdiagramId - Drag-drop:
onDragOversetsdropEffect,onDropreadsapplication/reactflow-devicedata, creates a node at drop position - Node/edge updates: Callbacks passed to PropertiesPanel that update React Flow state
- AI generation:
onGeneratecallback — replace mode replaces all nodes/edges, merge mode appends - Export PNG: Use
getNodesBounds+ canvas rendering - Export PDF: Use
window.print()with print stylesheet - Export JSON: Call API, trigger download
- Layout: DiagramHeader on top, then flex row with DeviceToolbar | ReactFlowProvider > NetworkCanvas | PropertiesPanel, AIAssistPanel at bottom
import { useState, useCallback, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
addEdge,
useReactFlow,
type Node,
type Edge,
type Connection,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { DiagramHeader } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
import { networkDiagramsApi, deviceTypesApi } from '@/api'
import { toast } from '@/lib/toast'
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge } from '@/types'
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
function DiagramEditorInner() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { getViewport, getNodes, fitView } = useReactFlow()
// Diagram metadata
const [diagramId, setDiagramId] = useState<string | null>(id || null)
const [name, setName] = useState('Untitled Diagram')
const [clientName, setClientName] = useState<string | null>(null)
const [assetName, setAssetName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
// Canvas state
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
// Selection
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null)
// Save state
const [isDirty, setIsDirty] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const isDirtyRef = useRef(false)
const diagramIdRef = useRef<string | null>(id || null)
// Device types
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
// Loading
const [loading, setLoading] = useState(!!id)
// Keep refs in sync
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
// Mark dirty on node/edge changes
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
onNodesChange(changes)
// Don't mark dirty for selection-only changes
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onNodesChange])
const handleEdgesChange: typeof onEdgesChange = useCallback((changes) => {
onEdgesChange(changes)
const hasRealChange = changes.some(c => c.type !== 'select')
if (hasRealChange) setIsDirty(true)
}, [onEdgesChange])
// Load device types
const loadDeviceTypes = useCallback(async () => {
try {
const types = await deviceTypesApi.list()
setDeviceTypes(types)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes])
// Load existing diagram
useEffect(() => {
if (!id) return
let cancelled = false
;(async () => {
try {
const diagram = await networkDiagramsApi.get(id)
if (cancelled) return
setName(diagram.name)
setClientName(diagram.client_name)
setAssetName(diagram.asset_name)
setDescription(diagram.description)
setNodes(
diagram.nodes.map(n => ({
id: n.id,
type: 'device',
position: n.position,
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}))
)
setEdges(
diagram.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: {
connectionType: e.connectionType,
speed: e.speed,
notes: e.notes,
},
}))
)
setLastSavedAt(new Date(diagram.updated_at))
} catch {
toast.error('Failed to load diagram')
navigate('/network-diagrams')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => { cancelled = true }
}, [id, navigate, setNodes, setEdges])
// Serialize current state for saving
const serializeNodes = useCallback(() => {
return getNodes().map(n => {
const data = n.data as unknown as DeviceNodeData
return {
id: n.id,
type: data.deviceType,
label: data.label,
position: n.position,
properties: data.properties,
}
})
}, [getNodes])
const serializeEdges = useCallback((): DiagramEdge[] => {
return edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
label: (e.label as string) || null,
connectionType: (e.data as Record<string, unknown>)?.connectionType as string || 'ethernet',
speed: (e.data as Record<string, unknown>)?.speed as string || null,
notes: (e.data as Record<string, unknown>)?.notes as string || null,
}))
}, [edges])
// Save
const handleSave = useCallback(async () => {
setIsSaving(true)
try {
const payload = {
name,
client_name: clientName,
asset_name: assetName,
description,
nodes: serializeNodes(),
edges: serializeEdges(),
}
if (diagramIdRef.current) {
await networkDiagramsApi.update(diagramIdRef.current, payload)
} else {
const created = await networkDiagramsApi.create(payload)
setDiagramId(created.id)
navigate(`/network-diagrams/${created.id}`, { replace: true })
}
setIsDirty(false)
setLastSavedAt(new Date())
} catch {
toast.error('Failed to save diagram')
} finally {
setIsSaving(false)
}
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
// Auto-save every 30 seconds
useEffect(() => {
const interval = setInterval(() => {
if (isDirtyRef.current && diagramIdRef.current) {
handleSave()
}
}, 30_000)
return () => clearInterval(interval)
}, [handleSave])
// Connect edges
const onConnect = useCallback((connection: Connection) => {
setEdges(eds => addEdge({
...connection,
type: 'connection',
data: { connectionType: 'ethernet' },
}, eds))
setIsDirty(true)
}, [setEdges])
// Drag-drop from toolbar
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}, [])
const onDrop = useCallback((event: React.DragEvent) => {
event.preventDefault()
const raw = event.dataTransfer.getData('application/reactflow-device')
if (!raw) return
const { slug, label, category } = JSON.parse(raw) as { slug: string; label: string; category: string }
const reactFlowBounds = (event.target as HTMLElement).closest('.react-flow')?.getBoundingClientRect()
if (!reactFlowBounds) return
const position = {
x: event.clientX - reactFlowBounds.left,
y: event.clientY - reactFlowBounds.top,
}
const newNode: Node = {
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
type: 'device',
position,
data: {
label,
deviceType: slug,
category,
properties: {
hostname: null,
ip: null,
subnet: null,
vendor: null,
model: null,
role: null,
vlan: null,
notes: null,
status: 'unknown',
} satisfies DeviceProperties,
} satisfies DeviceNodeData,
}
setNodes(nds => [...nds, newNode])
setIsDirty(true)
}, [setNodes])
// Node/edge updates from PropertiesPanel
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
setNodes(nds => nds.map(n => {
if (n.id !== nodeId) return n
return { ...n, data: { ...n.data, ...updates } }
}))
setIsDirty(true)
}, [setNodes])
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e
return {
...e,
label: updates.label !== undefined ? (updates.label || undefined) : e.label,
data: {
...(e.data || {}),
...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
...(updates.speed !== undefined ? { speed: updates.speed } : {}),
...(updates.notes !== undefined ? { notes: updates.notes } : {}),
},
}
}))
// Update selected edge reference
setSelectedEdge(prev => {
if (!prev || prev.id !== edgeId) return prev
return {
...prev,
label: updates.label !== undefined ? (updates.label || undefined) : prev.label,
data: {
...(prev.data || {}),
...(updates.connectionType !== undefined ? { connectionType: updates.connectionType } : {}),
...(updates.speed !== undefined ? { speed: updates.speed } : {}),
...(updates.notes !== undefined ? { notes: updates.notes } : {}),
},
}
})
setIsDirty(true)
}, [setEdges])
const handleDeleteNode = useCallback((nodeId: string) => {
setNodes(nds => nds.filter(n => n.id !== nodeId))
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
setSelectedNode(null)
setIsDirty(true)
}, [setNodes, setEdges])
const handleDeleteEdge = useCallback((edgeId: string) => {
setEdges(eds => eds.filter(e => e.id !== edgeId))
setSelectedEdge(null)
setIsDirty(true)
}, [setEdges])
// AI generation
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
const newNodes: Node[] = result.nodes.map(n => ({
id: n.id,
type: 'device',
position: n.position,
data: {
label: n.label,
deviceType: n.type,
properties: n.properties,
} satisfies DeviceNodeData,
}))
const newEdges: Edge[] = result.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection',
label: e.label || undefined,
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
}))
if (mode === 'replace') {
setNodes(newNodes)
setEdges(newEdges)
} else {
setNodes(nds => [...nds, ...newNodes])
setEdges(eds => [...eds, ...newEdges])
}
if (result.suggestedName && !diagramId) {
setName(result.suggestedName)
toast.success(`Generated: ${result.suggestedName}`)
} else {
toast.success('Diagram generated')
}
if (result.notes) {
toast.info(result.notes)
}
setIsDirty(true)
setTimeout(() => fitView({ padding: 0.2 }), 100)
}, [setNodes, setEdges, diagramId, fitView])
const getExistingBounds = useCallback(() => {
const currentNodes = getNodes()
if (currentNodes.length === 0) return null
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity
for (const n of currentNodes) {
minX = Math.min(minX, n.position.x)
maxX = Math.max(maxX, n.position.x + 120)
minY = Math.min(minY, n.position.y)
maxY = Math.max(maxY, n.position.y + 80)
}
return { minX, maxX, minY, maxY }
}, [getNodes])
// Export PNG — simplified canvas approach
const handleExportPng = useCallback(() => {
toast.info('PNG export — use your browser\'s screenshot tool or Print > Save as Image for now')
}, [])
// Export PDF via print
const handleExportPdf = useCallback(() => {
window.print()
}, [])
// Export JSON
const handleExportJson = useCallback(async () => {
if (!diagramId) return
try {
const data = await networkDiagramsApi.exportJson(diagramId)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '')}.json`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('Failed to export diagram')
}
}, [diagramId, name])
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</div>
)
}
return (
<div className="flex h-full flex-col">
<DiagramHeader
name={name}
clientName={clientName}
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
onNameChange={n => { setName(n); setIsDirty(true) }}
onSave={handleSave}
onExportPng={handleExportPng}
onExportPdf={handleExportPdf}
onExportJson={handleExportJson}
/>
<div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
<div className="flex flex-1 flex-col min-h-0">
<div className="flex-1 min-h-0">
<NetworkCanvas
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodeSelect={setSelectedNode}
onEdgeSelect={setSelectedEdge}
onDrop={onDrop}
onDragOver={onDragOver}
/>
</div>
<AIAssistPanel
onGenerate={handleAIGenerate}
getExistingBounds={getExistingBounds}
hasNodes={nodes.length > 0}
/>
</div>
<PropertiesPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
onNodeUpdate={handleNodeUpdate}
onEdgeUpdate={handleEdgeUpdate}
onDeleteNode={handleDeleteNode}
onDeleteEdge={handleDeleteEdge}
/>
</div>
</div>
)
}
export default function DiagramEditor() {
return (
<ReactFlowProvider>
<DiagramEditorInner />
</ReactFlowProvider>
)
}
- Step 2: Commit
git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
git commit -m "feat: add DiagramEditor page assembling all panels with auto-save and AI generation"
Task 15: Network Diagrams List Page
Files:
-
Create:
frontend/src/pages/NetworkDiagrams/index.tsx -
Step 1: Create the list page
Create frontend/src/pages/NetworkDiagrams/index.tsx:
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, Network, MoreHorizontal, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import { toast } from '@/lib/toast'
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
export default function NetworkDiagramsPage() {
const navigate = useNavigate()
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [clientFilter, setClientFilter] = useState<string | null>(null)
const [clientOptions, setClientOptions] = useState<string[]>([])
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
const [clientSearch, setClientSearch] = useState('')
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const loadDiagrams = useCallback(async () => {
try {
const params: Record<string, string> = {}
if (clientFilter) params.client_name = clientFilter
if (search) params.search = search
const data = await networkDiagramsApi.list(params)
setDiagrams(data)
} catch {
toast.error('Failed to load diagrams')
} finally {
setLoading(false)
}
}, [clientFilter, search])
const loadClients = useCallback(async () => {
try {
const clients = await networkDiagramsApi.listClients()
setClientOptions(clients)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadDiagrams() }, [loadDiagrams])
useEffect(() => { loadClients() }, [loadClients])
const filteredClients = useMemo(() => {
if (!clientSearch) return clientOptions
const lower = clientSearch.toLowerCase()
return clientOptions.filter(c => c.toLowerCase().includes(lower))
}, [clientOptions, clientSearch])
const handleDuplicate = useCallback(async (id: string) => {
try {
const dup = await networkDiagramsApi.duplicate(id)
toast.success(`Created: ${dup.name}`)
loadDiagrams()
} catch {
toast.error('Failed to duplicate')
}
setMenuOpenId(null)
}, [loadDiagrams])
const handleArchive = useCallback(async (id: string) => {
try {
await networkDiagramsApi.archive(id)
toast.success('Diagram archived')
loadDiagrams()
} catch {
toast.error('Failed to archive')
}
setMenuOpenId(null)
}, [loadDiagrams])
const handleImport = useCallback(async () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.json'
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text) as DiagramImportData
const result = await networkDiagramsApi.importJson(data)
if (result.warnings.length > 0) {
toast.warning(`Imported with warnings: ${result.warnings.join(', ')}`)
} else {
toast.success('Diagram imported')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch {
toast.error('Failed to import — check that the file is a valid diagram JSON')
}
}
input.click()
}, [navigate])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
return (
<div className="mx-auto max-w-6xl px-6 py-8">
{/* Header */}
<div className="mb-6 flex items-start justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-heading">Network Maps</h1>
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
</div>
<div className="flex gap-2">
<button
onClick={handleImport}
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import
</button>
<button
onClick={() => navigate('/network-diagrams/new')}
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
<Plus size={14} />
New Diagram
</button>
</div>
</div>
{/* Filters */}
<div className="mb-6 flex gap-3">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search diagrams..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full rounded border border-default bg-input pl-9 pr-3 py-2 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
{/* Client combobox */}
<div className="relative w-48">
<button
onClick={() => setClientDropdownOpen(prev => !prev)}
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
>
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
{clientFilter || 'All clients'}
</span>
<span className="text-muted-foreground">▾</span>
</button>
{clientDropdownOpen && (
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
<div className="p-2">
<input
type="text"
placeholder="Search clients..."
value={clientSearch}
onChange={e => setClientSearch(e.target.value)}
className="w-full rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto">
<button
onClick={() => { setClientFilter(null); setClientDropdownOpen(false); setClientSearch('') }}
className={cn('w-full px-3 py-1.5 text-left text-xs hover:bg-elevated', !clientFilter && 'text-accent')}
>
All clients
</button>
{filteredClients.map(c => (
<button
key={c}
onClick={() => { setClientFilter(c); setClientDropdownOpen(false); setClientSearch('') }}
className={cn('w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated', clientFilter === c && 'text-accent')}
>
{c}
</button>
))}
</div>
</div>
)}
</div>
</div>
{/* Loading */}
{loading && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-40 animate-pulse rounded-lg border border-default bg-card" />
))}
</div>
)}
{/* Empty state */}
{!loading && diagrams.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<Network size={48} className="mb-4 text-muted-foreground" />
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
<button
onClick={() => navigate('/network-diagrams/new')}
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
Create First Diagram
</button>
</div>
)}
{/* Diagram cards */}
{!loading && diagrams.length > 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{diagrams.map(d => (
<div
key={d.id}
onClick={() => navigate(`/network-diagrams/${d.id}`)}
className="group relative cursor-pointer rounded-lg border border-default bg-card p-4 hover:border-hover"
>
<div className="mb-2 flex items-start justify-between">
<h3 className="font-heading text-sm font-semibold text-heading">{d.name}</h3>
<button
onClick={e => { e.stopPropagation(); setMenuOpenId(menuOpenId === d.id ? null : d.id) }}
className="rounded p-1 text-muted-foreground opacity-0 hover:bg-elevated group-hover:opacity-100"
>
<MoreHorizontal size={14} />
</button>
</div>
{d.client_name && (
<span className="mb-2 inline-block rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
{d.client_name}
</span>
)}
{d.description && (
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
)}
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
<span>{formatDate(d.created_at)}</span>
</div>
{/* Context menu */}
{menuOpenId === d.id && (
<div className="absolute right-2 top-10 z-50 w-36 rounded border border-default bg-card py-1 shadow-lg">
<button
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
Open
</button>
<button
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
Duplicate
</button>
<button
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
>
Archive
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
- Step 2: Commit
git add frontend/src/pages/NetworkDiagrams/index.tsx
git commit -m "feat: add Network Diagrams list page with search, client filter, import"
Task 16: Navigation Integration (Sidebar + Router)
Files:
-
Modify:
frontend/src/components/layout/Sidebar.tsx -
Modify:
frontend/src/router.tsx -
Step 1: Add "Network Maps" to Sidebar rail groups
In frontend/src/components/layout/Sidebar.tsx, find the Flows rail group entry (the one with href: '/trees' and icon: GitBranch). Add { href: '/network-diagrams', label: 'Network Maps' } to its children array, after the Projects entry and before Solutions Library.
Also add '/network-diagrams' to the matchPaths array.
- Step 2: Add "Network Maps" to Sidebar pinned sections
In the sections array under KNOWLEDGE, add a new NavEntry after the Flow Library entry:
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
Import Network from lucide-react (add to the existing import).
- Step 3: Add routes to router.tsx
Add lazy imports at the top (after the existing lazy imports):
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
Add routes inside the ProtectedRoute/AppLayout children array:
{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
- Step 4: Verify TypeScript compiles
Run:
cd frontend && npx tsc -b
Expected: No errors.
- Step 5: Commit
git add frontend/src/components/layout/Sidebar.tsx frontend/src/router.tsx
git commit -m "feat: add Network Maps to sidebar navigation and router"
Task 17: Final Verification
- Step 1: Run Alembic migrations
cd backend && source venv/bin/activate && alembic upgrade head
Expected: Migrations 073 and 074 apply successfully.
- Step 2: Verify backend starts
cd backend && uvicorn app.main:app --reload &
sleep 3
curl -s http://localhost:8000/api/docs | grep -c "network-diagrams"
kill %1
Expected: At least 1 match (endpoints appear in OpenAPI docs).
- Step 3: Verify frontend builds
cd frontend && npx tsc -b
Expected: No TypeScript errors.
- Step 4: Verify full build (stricter)
cd frontend && npm run build
Expected: Build succeeds (or fails only on EACCES permission, per lesson #105 — TS compilation itself should succeed).
- Step 5: Final commit with all files verified
If any files were missed or need tweaking, add and commit:
git add -A && git status
Review staged files — only network diagram related files should be included. Then commit if needed:
git commit -m "chore: final verification and cleanup for network diagrams feature"