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