From 947516f81e9c58c4ab99dba972009bab947449b9 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:40:12 +0000 Subject: [PATCH 01/47] feat: add device_types table with system seed data Creates DeviceType SQLAlchemy model and migration 073 that provisions the device_types table with 28 system-seeded device types across 7 categories (network, compute, storage, cloud, endpoint, infrastructure, security). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../versions/073_add_device_types_table.py | 95 +++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/device_type.py | 51 ++++++++++ 3 files changed, 148 insertions(+) create mode 100644 backend/alembic/versions/073_add_device_types_table.py create mode 100644 backend/app/models/device_type.py diff --git a/backend/alembic/versions/073_add_device_types_table.py b/backend/alembic/versions/073_add_device_types_table.py new file mode 100644 index 00000000..fb1b86e0 --- /dev/null +++ b/backend/alembic/versions/073_add_device_types_table.py @@ -0,0 +1,95 @@ +"""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 = [ + ("router", "Router", "network", 0), + ("switch", "Switch", "network", 1), + ("firewall", "Firewall", "network", 2), + ("access-point", "Access Point", "network", 3), + ("load-balancer", "Load Balancer", "network", 4), + ("server", "Server", "compute", 0), + ("workstation", "Workstation", "compute", 1), + ("vm", "Virtual Machine", "compute", 2), + ("container", "Container", "compute", 3), + ("nas", "NAS", "storage", 0), + ("san", "SAN", "storage", 1), + ("cloud-storage", "Cloud Storage", "storage", 2), + ("cloud", "Cloud", "cloud", 0), + ("aws", "AWS", "cloud", 1), + ("azure", "Azure", "cloud", 2), + ("gcp", "Google Cloud", "cloud", 3), + ("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), + ("ups", "UPS", "infrastructure", 0), + ("pdu", "PDU", "infrastructure", 1), + ("rack", "Rack", "infrastructure", 2), + ("patch-panel", "Patch Panel", "infrastructure", 3), + ("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()")), + ) + + 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"]) + + 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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0441624f..895e82be 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -56,6 +56,7 @@ from .session_handoff import SessionHandoff from .session_resolution_output import SessionResolutionOutput from .template_tree import TemplateTree from .platform_step import PlatformStep +from .device_type import DeviceType __all__ = [ "User", @@ -126,4 +127,5 @@ __all__ = [ "SessionResolutionOutput", "TemplateTree", "PlatformStep", + "DeviceType", ] diff --git a/backend/app/models/device_type.py b/backend/app/models/device_type.py new file mode 100644 index 00000000..cb419cfb --- /dev/null +++ b/backend/app/models/device_type.py @@ -0,0 +1,51 @@ +"""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) + ) -- 2.49.1 From bb35cff38d29097574c994ad985914a3d60b7fa4 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:41:13 +0000 Subject: [PATCH 02/47] feat: add network_diagrams table Create NetworkDiagram SQLAlchemy model with JSONB nodes/edges, team-scoped with client/asset metadata, and Alembic migration 074. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../074_add_network_diagrams_table.py | 41 ++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/network_diagram.py | 53 +++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 backend/alembic/versions/074_add_network_diagrams_table.py create mode 100644 backend/app/models/network_diagram.py diff --git a/backend/alembic/versions/074_add_network_diagrams_table.py b/backend/alembic/versions/074_add_network_diagrams_table.py new file mode 100644 index 00000000..a95bd89b --- /dev/null +++ b/backend/alembic/versions/074_add_network_diagrams_table.py @@ -0,0 +1,41 @@ +"""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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 895e82be..5346c6ec 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -57,6 +57,7 @@ from .session_resolution_output import SessionResolutionOutput from .template_tree import TemplateTree from .platform_step import PlatformStep from .device_type import DeviceType +from .network_diagram import NetworkDiagram __all__ = [ "User", @@ -128,4 +129,5 @@ __all__ = [ "TemplateTree", "PlatformStep", "DeviceType", + "NetworkDiagram", ] diff --git a/backend/app/models/network_diagram.py b/backend/app/models/network_diagram.py new file mode 100644 index 00000000..a8ba7092 --- /dev/null +++ b/backend/app/models/network_diagram.py @@ -0,0 +1,53 @@ +"""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), + ) + + creator: Mapped[Optional["User"]] = relationship("User", foreign_keys=[created_by]) -- 2.49.1 From 973efb1f814dbf83ee260ee29c54eed169709793 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:42:18 +0000 Subject: [PATCH 03/47] feat: add Pydantic schemas for device types and network diagrams Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/schemas/device_type.py | 38 ++++++++ backend/app/schemas/network_diagram.py | 130 +++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 backend/app/schemas/device_type.py create mode 100644 backend/app/schemas/network_diagram.py diff --git a/backend/app/schemas/device_type.py b/backend/app/schemas/device_type.py new file mode 100644 index 00000000..8d2b70a2 --- /dev/null +++ b/backend/app/schemas/device_type.py @@ -0,0 +1,38 @@ +"""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): + 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): + 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): + 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} diff --git a/backend/app/schemas/network_diagram.py b/backend/app/schemas/network_diagram.py new file mode 100644 index 00000000..2b25356a --- /dev/null +++ b/backend/app/schemas/network_diagram.py @@ -0,0 +1,130 @@ +"""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): + 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): + id: str + type: str + label: str + position: dict + properties: DeviceProperties = Field(default_factory=DeviceProperties) + + +class DiagramEdge(BaseModel): + id: str + source: str + target: str + label: str | None = None + connectionType: str = "ethernet" + speed: str | None = None + notes: str | None = None + + +class NetworkDiagramCreate(BaseModel): + 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): + 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): + 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): + 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): + minX: float + maxX: float + minY: float + maxY: float + + +class AIGenerateRequest(BaseModel): + 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): + nodes: list[DiagramNode] + edges: list[DiagramEdge] + suggestedName: str | None = None + notes: str | None = None + + +class DiagramImportRequest(BaseModel): + 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): + diagram: NetworkDiagramResponse + warnings: list[str] = Field(default_factory=list) + + +class DiagramExportResponse(BaseModel): + schemaVersion: int = 1 + name: str + client_name: str | None = None + description: str | None = None + nodes: list[DiagramNode] + edges: list[DiagramEdge] + exportedAt: str -- 2.49.1 From c16f3968d54e37cbd53aec2af258488c53a761e8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:43:23 +0000 Subject: [PATCH 04/47] feat: add device types CRUD router Adds GET/POST/PUT/DELETE endpoints at /device-types with team-scoped access. System types are read-only; custom types are scoped to the creating team. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/device_types.py | 119 ++++++++++++++++++++++ backend/app/api/router.py | 42 +++++++- 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/endpoints/device_types.py diff --git a/backend/app/api/endpoints/device_types.py b/backend/app/api/endpoints/device_types.py new file mode 100644 index 00000000..7daa409c --- /dev/null +++ b/backend/app/api/endpoints/device_types.py @@ -0,0 +1,119 @@ +"""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]: + 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: + 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") + + 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: + 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: + 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() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index ed32ba58..8cf7d7bb 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,3 +1,4 @@ +<<<<<<< HEAD from fastapi import APIRouter, Depends from app.api.deps import require_tenant_context @@ -24,6 +25,7 @@ from app.api.endpoints import ( branding, categories, copilot, + device_types, feedback, flow_proposals, flowpilot_analytics, @@ -58,6 +60,44 @@ from app.api.endpoints import ( webhooks, accounts, ) +======= +from fastapi import APIRouter +from app.api.endpoints import auth, trees, sessions, sidebar, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown +from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories +from app.api.endpoints import ratings, analytics +from app.api.endpoints import target_lists +from app.api.endpoints import maintenance_schedules +from app.api.endpoints import feedback +from app.api.endpoints import ai_builder +from app.api.endpoints import ai_fix +from app.api.endpoints import ai_chat +from app.api.endpoints import copilot +from app.api.endpoints import assistant_chat +from app.api.endpoints import survey +from app.api.endpoints import admin_survey +from app.api.endpoints import tree_transfer +from app.api.endpoints import ai_suggestions +from app.api.endpoints import kb_accelerator +from app.api.endpoints import beta_signup +from app.api.endpoints import scripts +from app.api.endpoints import integrations +from app.api.endpoints import onboarding +from app.api.endpoints import branding +from app.api.endpoints import supporting_data +from app.api.endpoints import ai_sessions +from app.api.endpoints import flow_proposals +from app.api.endpoints import flowpilot_analytics +from app.api.endpoints import notifications +from app.api.endpoints import public_templates +from app.api.endpoints import admin_gallery +from app.api.endpoints import uploads +from app.api.endpoints import script_builder +from app.api.endpoints import beta_feedback +from app.api.endpoints import session_branches +from app.api.endpoints import session_handoffs +from app.api.endpoints import session_resolutions +from app.api.endpoints import device_types +>>>>>>> a3c4987 (feat: add device types CRUD router) api_router = APIRouter() @@ -93,7 +133,6 @@ api_router.include_router(admin_settings.router) api_router.include_router(admin_categories.router) api_router.include_router(admin_survey.router) api_router.include_router(admin_gallery.router) - # --------------------------------------------------------------------------- # User-facing endpoints — tenant context required # --------------------------------------------------------------------------- @@ -142,3 +181,4 @@ api_router.include_router(script_builder.router, dependencies=_tenant_deps) api_router.include_router(beta_feedback.router, dependencies=_tenant_deps) api_router.include_router(session_branches.router, dependencies=_tenant_deps) api_router.include_router(session_handoffs.router, dependencies=_tenant_deps) +api_router.include_router(device_types.router, dependencies=_tenant_deps) -- 2.49.1 From 24afe5eb4108e5f463a23b669ab408d4c066c2dc Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:44:00 +0000 Subject: [PATCH 05/47] feat: add AI generation service for network diagrams Adds network_diagram_ai_service.py with generate_diagram() function that calls the AI provider to convert plain-English network descriptions into structured DiagramNode/DiagramEdge data. Registers the action in ACTION_MODEL_MAP as a standard-tier route. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/core/config.py | 1 + .../services/network_diagram_ai_service.py | 146 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 backend/app/services/network_diagram_ai_service.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 5d31b789..90db6f83 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -128,6 +128,7 @@ class Settings(BaseSettings): "variable_inference": "fast", "kb_convert": "standard", "script_build": "standard", + "network_diagram_generate": "standard", } def get_model_for_action(self, action_type: str) -> str: diff --git a/backend/app/services/network_diagram_ai_service.py b/backend/app/services/network_diagram_ai_service.py new file mode 100644 index 00000000..61defa50 --- /dev/null +++ b/backend/app/services/network_diagram_ai_service.py @@ -0,0 +1,146 @@ +"""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: + 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, + ) + + 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") + + nodes = [] + for raw_node in data.get("nodes", []): + 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 + }), + )) + + 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"), + ) -- 2.49.1 From 074548678f186c654d2fe50155451db24b6837c1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:45:42 +0000 Subject: [PATCH 06/47] feat: add network diagrams CRUD + AI generate + export/import router Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/network_diagrams.py | 297 ++++++++++++++++++ backend/app/api/router.py | 41 +-- 2 files changed, 299 insertions(+), 39 deletions(-) create mode 100644 backend/app/api/endpoints/network_diagrams.py diff --git a/backend/app/api/endpoints/network_diagrams.py b/backend/app/api/endpoints/network_diagrams.py new file mode 100644 index 00000000..a54478d2 --- /dev/null +++ b/backend/app/api/endpoints/network_diagrams.py @@ -0,0 +1,297 @@ +"""Network diagrams API endpoints.""" +import logging +from datetime import datetime, timezone +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +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.models.network_diagram import NetworkDiagram +from app.schemas.network_diagram import ( + NetworkDiagramCreate, + NetworkDiagramUpdate, + NetworkDiagramResponse, + NetworkDiagramListItem, + AIGenerateRequest, + AIGenerateResponse, + DiagramImportRequest, + DiagramImportResponse, + DiagramExportResponse, + DiagramNode, + DiagramEdge, +) +from app.services import network_diagram_ai_service + +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: + 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: + return NetworkDiagramResponse.model_validate(diagram) + + +def _diagram_to_list_item(diagram: NetworkDiagram) -> NetworkDiagramListItem: + 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]: + 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]: + 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: + 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: + 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: + 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: + 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: + 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: + diagram = await _get_diagram_or_404(diagram_id, current_user.team_id, db) + nodes = [DiagramNode(**n) for n in (diagram.nodes or [])] + edges = [DiagramEdge(**e) for e in (diagram.edges or [])] + 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: + 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: + 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()] + + existing_node_ids: list[str] | None = None + if data.mode == "merge" and data.existingBounds: + 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") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 8cf7d7bb..349f5969 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,4 +1,3 @@ -<<<<<<< HEAD from fastapi import APIRouter, Depends from app.api.deps import require_tenant_context @@ -34,6 +33,7 @@ from app.api.endpoints import ( invite, kb_accelerator, maintenance_schedules, + network_diagrams, notifications, onboarding, public_templates, @@ -60,44 +60,6 @@ from app.api.endpoints import ( webhooks, accounts, ) -======= -from fastapi import APIRouter -from app.api.endpoints import auth, trees, sessions, sidebar, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown -from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories -from app.api.endpoints import ratings, analytics -from app.api.endpoints import target_lists -from app.api.endpoints import maintenance_schedules -from app.api.endpoints import feedback -from app.api.endpoints import ai_builder -from app.api.endpoints import ai_fix -from app.api.endpoints import ai_chat -from app.api.endpoints import copilot -from app.api.endpoints import assistant_chat -from app.api.endpoints import survey -from app.api.endpoints import admin_survey -from app.api.endpoints import tree_transfer -from app.api.endpoints import ai_suggestions -from app.api.endpoints import kb_accelerator -from app.api.endpoints import beta_signup -from app.api.endpoints import scripts -from app.api.endpoints import integrations -from app.api.endpoints import onboarding -from app.api.endpoints import branding -from app.api.endpoints import supporting_data -from app.api.endpoints import ai_sessions -from app.api.endpoints import flow_proposals -from app.api.endpoints import flowpilot_analytics -from app.api.endpoints import notifications -from app.api.endpoints import public_templates -from app.api.endpoints import admin_gallery -from app.api.endpoints import uploads -from app.api.endpoints import script_builder -from app.api.endpoints import beta_feedback -from app.api.endpoints import session_branches -from app.api.endpoints import session_handoffs -from app.api.endpoints import session_resolutions -from app.api.endpoints import device_types ->>>>>>> a3c4987 (feat: add device types CRUD router) api_router = APIRouter() @@ -169,6 +131,7 @@ api_router.include_router(integrations.router, dependencies=_tenant_deps) api_router.include_router(onboarding.router, dependencies=_tenant_deps) api_router.include_router(branding.router, dependencies=_tenant_deps) api_router.include_router(supporting_data.router, dependencies=_tenant_deps) +api_router.include_router(network_diagrams.router, dependencies=_tenant_deps) # session_handoffs queue router must come before ai_sessions to avoid conflict api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps) api_router.include_router(session_resolutions.router, dependencies=_tenant_deps) -- 2.49.1 From b9e37ecdfb4317c8c2d263eaa86eec21f59589c4 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:48:00 +0000 Subject: [PATCH 07/47] feat: add TypeScript types for network diagrams Adds all interfaces for network diagrams and device types including DiagramNode, DiagramEdge, DeviceProperties, NetworkDiagramResponse, AI generate request/response, import/export shapes, and list item types. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/types/index.ts | 1 + frontend/src/types/network-diagram.ts | 130 ++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 frontend/src/types/network-diagram.ts diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b44ccc84..e301c06e 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -98,3 +98,4 @@ export * from './script-builder' export * from './integrations' export * from './notification' export type * from './public-templates' +export * from './network-diagram' diff --git a/frontend/src/types/network-diagram.ts b/frontend/src/types/network-diagram.ts new file mode 100644 index 00000000..cfca8cc0 --- /dev/null +++ b/frontend/src/types/network-diagram.ts @@ -0,0 +1,130 @@ +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 +} -- 2.49.1 From 1ec7bbbbd3aeae9a962addeca760012dfbcddb15 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:48:03 +0000 Subject: [PATCH 08/47] feat: add frontend API clients for device types and network diagrams Adds deviceTypesApi (list, create, update, remove) and networkDiagramsApi (list, get, create, update, archive, duplicate, exportJson, importJson, aiGenerate, listClients) following the existing apiClient module pattern. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/api/deviceTypes.ts | 23 +++++++++++ frontend/src/api/index.ts | 2 + frontend/src/api/networkDiagrams.ts | 63 +++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 frontend/src/api/deviceTypes.ts create mode 100644 frontend/src/api/networkDiagrams.ts diff --git a/frontend/src/api/deviceTypes.ts b/frontend/src/api/deviceTypes.ts new file mode 100644 index 00000000..1425584a --- /dev/null +++ b/frontend/src/api/deviceTypes.ts @@ -0,0 +1,23 @@ +import apiClient from './client' +import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types' + +export const deviceTypesApi = { + async list(): Promise { + const response = await apiClient.get('/device-types/') + return response.data + }, + + async create(data: DeviceTypeCreate): Promise { + const response = await apiClient.post('/device-types/', data) + return response.data + }, + + async update(id: string, data: Partial): Promise { + const response = await apiClient.put(`/device-types/${id}`, data) + return response.data + }, + + async remove(id: string): Promise { + await apiClient.delete(`/device-types/${id}`) + }, +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b362e193..9ec66c9b 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -35,3 +35,5 @@ export { betaFeedbackApi } from './betaFeedback' export { branchesApi } from './branches' export { handoffsApi } from './handoffs' export { resolutionsApi } from './resolutions' +export { deviceTypesApi } from './deviceTypes' +export { networkDiagramsApi } from './networkDiagrams' diff --git a/frontend/src/api/networkDiagrams.ts b/frontend/src/api/networkDiagrams.ts new file mode 100644 index 00000000..c074fb00 --- /dev/null +++ b/frontend/src/api/networkDiagrams.ts @@ -0,0 +1,63 @@ +import apiClient from './client' +import type { + NetworkDiagramResponse, + NetworkDiagramListItem, + NetworkDiagramCreate, + NetworkDiagramUpdate, + AIGenerateRequest, + AIGenerateResponse, + DiagramImportData, + DiagramImportResponse, + DiagramExportResponse, +} from '@/types' + +export const networkDiagramsApi = { + async list(params?: { client_name?: string; search?: string }): Promise { + const response = await apiClient.get('/network-diagrams/', { params }) + return response.data + }, + + async get(id: string): Promise { + const response = await apiClient.get(`/network-diagrams/${id}`) + return response.data + }, + + async create(data: NetworkDiagramCreate): Promise { + const response = await apiClient.post('/network-diagrams/', data) + return response.data + }, + + async update(id: string, data: NetworkDiagramUpdate): Promise { + const response = await apiClient.put(`/network-diagrams/${id}`, data) + return response.data + }, + + async archive(id: string): Promise { + await apiClient.delete(`/network-diagrams/${id}`) + }, + + async duplicate(id: string): Promise { + const response = await apiClient.post(`/network-diagrams/${id}/duplicate`) + return response.data + }, + + async exportJson(id: string): Promise { + const response = await apiClient.get(`/network-diagrams/${id}/export`) + return response.data + }, + + async importJson(data: DiagramImportData): Promise { + const response = await apiClient.post('/network-diagrams/import', data) + return response.data + }, + + async aiGenerate(data: AIGenerateRequest): Promise { + const response = await apiClient.post('/network-diagrams/ai-generate', data) + return response.data + }, + + async listClients(): Promise { + const response = await apiClient.get('/network-diagrams/clients') + return response.data + }, +} -- 2.49.1 From 354b44844cccc37a5678ee66c4860d34a721fb14 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:50:10 +0000 Subject: [PATCH 09/47] feat: add device registry, DeviceNode, ConnectionEdge for React Flow Creates the React Flow building blocks for the network diagram editor: device type registry with icon/color mappings, DeviceNode component with status indicators and connection handles, ConnectionEdge with per-type styling, and nodeTypes/edgeTypes registration maps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../network/edges/ConnectionEdge.tsx | 54 ++++++++++++++ .../src/components/network/edges/edgeTypes.ts | 5 ++ .../components/network/nodes/DeviceNode.tsx | 51 +++++++++++++ .../network/nodes/deviceRegistry.ts | 73 +++++++++++++++++++ .../src/components/network/nodes/nodeTypes.ts | 5 ++ 5 files changed, 188 insertions(+) create mode 100644 frontend/src/components/network/edges/ConnectionEdge.tsx create mode 100644 frontend/src/components/network/edges/edgeTypes.ts create mode 100644 frontend/src/components/network/nodes/DeviceNode.tsx create mode 100644 frontend/src/components/network/nodes/deviceRegistry.ts create mode 100644 frontend/src/components/network/nodes/nodeTypes.ts diff --git a/frontend/src/components/network/edges/ConnectionEdge.tsx b/frontend/src/components/network/edges/ConnectionEdge.tsx new file mode 100644 index 00000000..0e38ba72 --- /dev/null +++ b/frontend/src/components/network/edges/ConnectionEdge.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react' +import { SmoothStepEdge, EdgeLabelRenderer, type EdgeProps } from '@xyflow/react' + +interface ConnectionEdgeData { + connectionType?: string + speed?: string | null + notes?: string | null + [key: string]: unknown +} + +const CONNECTION_STYLES: Record = { + ethernet: { stroke: '#60a5fa', strokeWidth: 2 }, + fiber: { stroke: '#34d399', strokeWidth: 3 }, + wifi: { stroke: '#a78bfa', strokeDasharray: '3,3', strokeWidth: 2 }, + vpn: { stroke: '#eab308', strokeDasharray: '8,4', strokeWidth: 2 }, + vlan: { stroke: '#848b9b', strokeWidth: 2 }, + wan: { stroke: '#f87171', strokeDasharray: '12,4', strokeWidth: 2 }, +} + +const DEFAULT_STYLE = { stroke: '#848b9b', strokeWidth: 2 } + +function ConnectionEdgeComponent(props: EdgeProps) { + const edgeData = props.data as ConnectionEdgeData | undefined + const connectionType = edgeData?.connectionType || 'ethernet' + const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE + + return ( + <> + + {props.label && ( + +
+ {props.label} +
+
+ )} + + ) +} + +export const ConnectionEdge = memo(ConnectionEdgeComponent) diff --git a/frontend/src/components/network/edges/edgeTypes.ts b/frontend/src/components/network/edges/edgeTypes.ts new file mode 100644 index 00000000..0c23c2f8 --- /dev/null +++ b/frontend/src/components/network/edges/edgeTypes.ts @@ -0,0 +1,5 @@ +import { ConnectionEdge } from './ConnectionEdge' + +export const edgeTypes = { + connection: ConnectionEdge, +} diff --git a/frontend/src/components/network/nodes/DeviceNode.tsx b/frontend/src/components/network/nodes/DeviceNode.tsx new file mode 100644 index 00000000..b0de9f61 --- /dev/null +++ b/frontend/src/components/network/nodes/DeviceNode.tsx @@ -0,0 +1,51 @@ +import { memo } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import { cn } from '@/lib/utils' +import { getDeviceRenderConfig } from './deviceRegistry' +import type { DeviceProperties } from '@/types' + +export interface DeviceNodeData { + label: string + deviceType: string + category?: string + properties: DeviceProperties + [key: string]: unknown +} + +const STATUS_COLORS: Record = { + online: 'bg-emerald-400', + offline: 'bg-red-400', + degraded: 'bg-yellow-400', + unknown: 'bg-gray-500', +} + +function DeviceNodeComponent({ data, selected }: NodeProps) { + const nodeData = data as unknown as DeviceNodeData + const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category) + const status = nodeData.properties?.status || 'unknown' + const ip = nodeData.properties?.ip + + return ( +
+
+ + {nodeData.label} + {ip && ( + {ip} + )} + + + + +
+ ) +} + +export const DeviceNode = memo(DeviceNodeComponent) diff --git a/frontend/src/components/network/nodes/deviceRegistry.ts b/frontend/src/components/network/nodes/deviceRegistry.ts new file mode 100644 index 00000000..a5d7b4a4 --- /dev/null +++ b/frontend/src/components/network/nodes/deviceRegistry.ts @@ -0,0 +1,73 @@ +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 +} + +const SYSTEM_DEVICE_ICONS: Record = { + '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)' }, + '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)' }, + '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': { 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)' }, + '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)' }, + '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)' }, + 'nvr': { icon: Camera, color: 'var(--color-text-muted-foreground)' }, + 'badge-reader': { icon: KeyRound, color: 'var(--color-text-muted-foreground)' }, +} + +const CATEGORY_DEFAULTS: Record = { + 'network': { icon: Network, color: 'var(--color-text-muted-foreground)' }, + 'compute': { icon: Server, color: 'var(--color-text-muted-foreground)' }, + 'storage': { icon: Database, color: 'var(--color-text-muted-foreground)' }, + 'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' }, + 'endpoint': { icon: Monitor, color: 'var(--color-text-muted-foreground)' }, + 'infrastructure': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' }, + 'security': { icon: Shield, color: 'var(--color-text-muted-foreground)' }, +} + +const FALLBACK: DeviceRenderConfig = { icon: Box, color: 'var(--color-text-muted-foreground)' } + +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 +} + +export const CATEGORY_LABELS: Record = { + 'network': 'Network', + 'compute': 'Compute', + 'storage': 'Storage', + 'cloud': 'Cloud', + 'endpoint': 'Endpoints', + 'infrastructure': 'Infrastructure', + 'security': 'Security', +} + +export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security'] diff --git a/frontend/src/components/network/nodes/nodeTypes.ts b/frontend/src/components/network/nodes/nodeTypes.ts new file mode 100644 index 00000000..d698081c --- /dev/null +++ b/frontend/src/components/network/nodes/nodeTypes.ts @@ -0,0 +1,5 @@ +import { DeviceNode } from './DeviceNode' + +export const nodeTypes = { + device: DeviceNode, +} -- 2.49.1 From ab49635de2c06f10b3e131cfc6c231544faadb98 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:51:44 +0000 Subject: [PATCH 10/47] feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation Co-Authored-By: Claude Opus 4.6 (1M context) --- .../network/panels/DeviceToolbar.tsx | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 frontend/src/components/network/panels/DeviceToolbar.tsx diff --git a/frontend/src/components/network/panels/DeviceToolbar.tsx b/frontend/src/components/network/panels/DeviceToolbar.tsx new file mode 100644 index 00000000..1af345a2 --- /dev/null +++ b/frontend/src/components/network/panels/DeviceToolbar.tsx @@ -0,0 +1,176 @@ +import { useState, useMemo, useCallback } from 'react' +import { Search, Plus, ChevronDown, ChevronRight, X } from 'lucide-react' +import { cn } from '@/lib/utils' +import { getDeviceRenderConfig, CATEGORY_LABELS, CATEGORY_ORDER } from '../nodes/deviceRegistry' +import type { DeviceTypeResponse, DeviceTypeCreate } from '@/types' +import { deviceTypesApi } from '@/api' + +interface DeviceToolbarProps { + deviceTypes: DeviceTypeResponse[] + onDeviceTypesChange: () => void +} + +export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolbarProps) { + const [search, setSearch] = useState('') + const [collapsedCategories, setCollapsedCategories] = useState>(new Set()) + const [showAddForm, setShowAddForm] = useState(false) + const [newType, setNewType] = useState({ slug: '', label: '', category: 'network' }) + const [addError, setAddError] = useState(null) + const [addLoading, setAddLoading] = useState(false) + + const filteredByCategory = useMemo(() => { + const lower = search.toLowerCase() + const filtered = search + ? deviceTypes.filter(dt => dt.label.toLowerCase().includes(lower) || dt.slug.toLowerCase().includes(lower)) + : deviceTypes + + const grouped: Record = {} + for (const dt of filtered) { + if (!grouped[dt.category]) grouped[dt.category] = [] + grouped[dt.category].push(dt) + } + return grouped + }, [deviceTypes, search]) + + const toggleCategory = useCallback((cat: string) => { + setCollapsedCategories(prev => { + const next = new Set(prev) + if (next.has(cat)) next.delete(cat) + else next.add(cat) + return next + }) + }, []) + + const handleDragStart = useCallback((e: React.DragEvent, deviceType: DeviceTypeResponse) => { + e.dataTransfer.setData('application/reactflow-device', JSON.stringify({ + slug: deviceType.slug, + label: deviceType.label, + category: deviceType.category, + })) + e.dataTransfer.effectAllowed = 'move' + }, []) + + const handleAddType = useCallback(async () => { + if (!newType.slug || !newType.label) { + setAddError('Slug and label are required') + return + } + setAddLoading(true) + setAddError(null) + try { + await deviceTypesApi.create(newType) + setNewType({ slug: '', label: '', category: 'network' }) + setShowAddForm(false) + onDeviceTypesChange() + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to create device type' + setAddError(msg) + } finally { + setAddLoading(false) + } + }, [newType, onDeviceTypesChange]) + + return ( +
+
+ + 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" + /> +
+ +
+ {CATEGORY_ORDER.map(cat => { + const items = filteredByCategory[cat] + if (!items?.length) return null + const collapsed = collapsedCategories.has(cat) + + return ( +
+ + {!collapsed && ( +
+ {items.map(dt => { + const { icon: Icon, color } = getDeviceRenderConfig(dt.slug, dt.category) + return ( +
handleDragStart(e, dt)} + className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing" + > + + {dt.label} +
+ ) + })} +
+ )} +
+ ) + })} +
+ +
+ {!showAddForm ? ( + + ) : ( +
+
+ New Type + +
+ setNewType(prev => ({ ...prev, slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '') }))} + className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none" + /> + setNewType(prev => ({ ...prev, label: e.target.value }))} + className="rounded border border-default bg-input px-2 py-1 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none" + /> + + {addError &&

{addError}

} + +
+ )} +
+
+ ) +} -- 2.49.1 From 25233dbfae20ae3c25e91ae764ad2773537b99dc Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 07:52:15 +0000 Subject: [PATCH 11/47] feat: add PropertiesPanel for node and edge property editing Co-Authored-By: Claude Opus 4.6 (1M context) --- .../network/panels/PropertiesPanel.tsx | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 frontend/src/components/network/panels/PropertiesPanel.tsx diff --git a/frontend/src/components/network/panels/PropertiesPanel.tsx b/frontend/src/components/network/panels/PropertiesPanel.tsx new file mode 100644 index 00000000..ee9b6b63 --- /dev/null +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -0,0 +1,229 @@ +import { useCallback } from 'react' +import { Trash2 } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { DeviceProperties, DiagramEdge } from '@/types' +import type { Node, Edge } from '@xyflow/react' +import type { DeviceNodeData } from '../nodes/DeviceNode' + +interface PropertiesPanelProps { + selectedNode: Node | null + selectedEdge: Edge | null + onNodeUpdate: (nodeId: string, data: Partial) => void + onEdgeUpdate: (edgeId: string, data: Partial) => void + onDeleteNode: (nodeId: string) => void + onDeleteEdge: (edgeId: string) => void +} + +const STATUS_OPTIONS = ['unknown', 'online', 'offline', 'degraded'] as const +const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const + +function FieldLabel({ children }: { children: React.ReactNode }) { + return ( + + ) +} + +function FieldInput({ value, onChange, placeholder, mono }: { + value: string + onChange: (val: string) => void + placeholder?: string + mono?: boolean +}) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + className={cn( + 'w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none', + mono && 'font-mono', + )} + /> + ) +} + +export function PropertiesPanel({ + selectedNode, + selectedEdge, + onNodeUpdate, + onEdgeUpdate, + onDeleteNode, + onDeleteEdge, +}: PropertiesPanelProps) { + const handlePropertyChange = useCallback((field: keyof DeviceProperties, value: string) => { + if (!selectedNode) return + const nodeData = selectedNode.data as unknown as DeviceNodeData + onNodeUpdate(selectedNode.id, { + properties: { ...nodeData.properties, [field]: value }, + } as Partial) + }, [selectedNode, onNodeUpdate]) + + const handleLabelChange = useCallback((value: string) => { + if (!selectedNode) return + onNodeUpdate(selectedNode.id, { label: value } as Partial) + }, [selectedNode, onNodeUpdate]) + + if (!selectedNode && !selectedEdge) { + return ( +
+

+ Select a device or connection to edit its properties +

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

Connection

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

Device Properties

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