Compare commits
47 Commits
main
...
feat/netwo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fb865ada8 | ||
|
|
89ca2a0fa5 | ||
|
|
112b1c2649 | ||
|
|
fd13a0618d | ||
|
|
3372e77a2a | ||
|
|
47353a68cd | ||
|
|
31324aa154 | ||
|
|
17ce5b1dfb | ||
|
|
dd1a13d713 | ||
|
|
2a6178e246 | ||
|
|
327a5c7c14 | ||
|
|
4527571d5f | ||
|
|
3c2b1dd16e | ||
|
|
bb24078d60 | ||
|
|
dd95b8892c | ||
|
|
0dc2801916 | ||
|
|
b490719667 | ||
|
|
663a96c8a5 | ||
|
|
2ea56f2563 | ||
|
|
6e5614e7b4 | ||
|
|
e6a4c93203 | ||
|
|
65ba60b2ae | ||
|
|
74c08f41c4 | ||
|
|
92ce84ef71 | ||
|
|
3c62a6993c | ||
|
|
a9c4bcc08b | ||
|
|
fe33ad1d5a | ||
|
|
3aaf0e58aa | ||
|
|
855cff07c2 | ||
|
|
87de51b06e | ||
|
|
f6e7613a5e | ||
|
|
ddd55167c1 | ||
|
|
2622258b04 | ||
|
|
90d7aa04a9 | ||
|
|
2a977e4d81 | ||
|
|
1371c2edd9 | ||
|
|
25233dbfae | ||
|
|
ab49635de2 | ||
|
|
354b44844c | ||
|
|
1ec7bbbbd3 | ||
|
|
b9e37ecdfb | ||
|
|
074548678f | ||
|
|
24afe5eb41 | ||
|
|
c16f3968d5 | ||
|
|
973efb1f81 | ||
|
|
bb35cff38d | ||
|
|
947516f81e |
132
backend/alembic/versions/073_add_device_types_table.py
Normal file
132
backend/alembic/versions/073_add_device_types_table.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Add account-scoped device_types table with platform seed data.
|
||||||
|
|
||||||
|
Revision ID: 073
|
||||||
|
Revises: b3c7e9f2a1d8
|
||||||
|
Create Date: 2026-04-12
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
revision = "073"
|
||||||
|
down_revision = "b3c7e9f2a1d8"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_PLATFORM_UUID = "00000000-0000-0000-0000-000000000001"
|
||||||
|
_CURRENT_ACCOUNT = (
|
||||||
|
"COALESCE("
|
||||||
|
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||||
|
"'00000000-0000-0000-0000-000000000000'"
|
||||||
|
")::uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
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("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
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.create_unique_constraint("uq_device_types_slug_account", "device_types", ["slug", "account_id"])
|
||||||
|
op.create_index("ix_device_types_account_id", "device_types", ["account_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("account_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,
|
||||||
|
"account_id": uuid.UUID(_PLATFORM_UUID),
|
||||||
|
"sort_order": sort_order,
|
||||||
|
}
|
||||||
|
for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES
|
||||||
|
])
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE device_types ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("ALTER TABLE device_types FORCE ROW LEVEL SECURITY")
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE POLICY device_types_select ON device_types
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
account_id = {_CURRENT_ACCOUNT}
|
||||||
|
OR account_id = '{_PLATFORM_UUID}'::uuid
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE POLICY device_types_insert ON device_types
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||||
|
""")
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE POLICY device_types_update ON device_types
|
||||||
|
FOR UPDATE
|
||||||
|
USING (account_id = {_CURRENT_ACCOUNT})
|
||||||
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||||
|
""")
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE POLICY device_types_delete ON device_types
|
||||||
|
FOR DELETE
|
||||||
|
USING (account_id = {_CURRENT_ACCOUNT})
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS device_types_delete ON device_types")
|
||||||
|
op.execute("DROP POLICY IF EXISTS device_types_update ON device_types")
|
||||||
|
op.execute("DROP POLICY IF EXISTS device_types_insert ON device_types")
|
||||||
|
op.execute("DROP POLICY IF EXISTS device_types_select ON device_types")
|
||||||
|
op.execute("ALTER TABLE device_types DISABLE ROW LEVEL SECURITY")
|
||||||
|
op.drop_table("device_types")
|
||||||
57
backend/alembic/versions/074_add_network_diagrams_table.py
Normal file
57
backend/alembic/versions/074_add_network_diagrams_table.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add network_diagrams table.
|
||||||
|
|
||||||
|
Revision ID: 074
|
||||||
|
Revises: 073
|
||||||
|
Create Date: 2026-04-12
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
_CURRENT_ACCOUNT = (
|
||||||
|
"COALESCE("
|
||||||
|
"NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||||
|
"'00000000-0000-0000-0000-000000000000'"
|
||||||
|
")::uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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("account_id", UUID(as_uuid=True), sa.ForeignKey("accounts.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("ix_network_diagrams_account_id", "network_diagrams", ["account_id"])
|
||||||
|
op.create_index("idx_network_diagrams_account_client", "network_diagrams", ["account_id", "client_name"])
|
||||||
|
op.execute("ALTER TABLE network_diagrams ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute("ALTER TABLE network_diagrams FORCE ROW LEVEL SECURITY")
|
||||||
|
op.execute(f"""
|
||||||
|
CREATE POLICY tenant_isolation ON network_diagrams
|
||||||
|
USING (account_id = {_CURRENT_ACCOUNT})
|
||||||
|
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP POLICY IF EXISTS tenant_isolation ON network_diagrams")
|
||||||
|
op.execute("ALTER TABLE network_diagrams DISABLE ROW LEVEL SECURITY")
|
||||||
|
op.drop_table("network_diagrams")
|
||||||
120
backend/app/api/endpoints/device_types.py
Normal file
120
backend/app/api/endpoints/device_types.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
|
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||||
|
|
||||||
|
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.account_id == PLATFORM_ACCOUNT_ID,
|
||||||
|
DeviceType.account_id == current_user.account_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.account_id == current_user.account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=409, detail=f"Device type '{data.slug}' already exists for your account")
|
||||||
|
|
||||||
|
system_existing = await db.execute(
|
||||||
|
select(DeviceType).where(
|
||||||
|
DeviceType.slug == data.slug,
|
||||||
|
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
account_id=current_user.account_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.account_id != current_user.account_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.account_id != current_user.account_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Device type not found")
|
||||||
|
|
||||||
|
await db.delete(device_type)
|
||||||
|
await db.commit()
|
||||||
331
backend/app/api/endpoints/network_diagrams.py
Normal file
331
backend/app/api/endpoints/network_diagrams.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""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.core.service_account import PLATFORM_ACCOUNT_ID
|
||||||
|
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
|
||||||
|
|
||||||
|
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
|
||||||
|
_SLUG_CATEGORY: dict[str, str] = {
|
||||||
|
"router": "network", "switch": "network", "access-point": "network", "load-balancer": "network",
|
||||||
|
"firewall": "security", "badge-reader": "security",
|
||||||
|
"server": "compute", "vm": "compute", "container": "compute",
|
||||||
|
"nas": "storage", "san": "storage", "cloud-storage": "storage",
|
||||||
|
"cloud": "cloud", "aws": "cloud", "azure": "cloud", "gcp": "cloud", "isp": "cloud",
|
||||||
|
"workstation": "endpoint", "laptop": "endpoint", "tablet": "endpoint",
|
||||||
|
"phone": "endpoint", "printer": "endpoint",
|
||||||
|
"ups": "infrastructure", "pdu": "infrastructure", "rack": "infrastructure",
|
||||||
|
"patch-panel": "infrastructure", "camera": "infrastructure",
|
||||||
|
"nvr": "infrastructure", "iot": "infrastructure",
|
||||||
|
}
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_diagram_or_404(
|
||||||
|
diagram_id: UUID,
|
||||||
|
account_id: UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> NetworkDiagram:
|
||||||
|
diagram = await db.get(NetworkDiagram, diagram_id)
|
||||||
|
if not diagram or diagram.account_id != account_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,
|
||||||
|
custom_slug_category: dict[str, str] | None = None,
|
||||||
|
) -> NetworkDiagramListItem:
|
||||||
|
nodes = diagram.nodes if isinstance(diagram.nodes, list) else []
|
||||||
|
slug_to_cat = {**_SLUG_CATEGORY, **(custom_slug_category or {})}
|
||||||
|
|
||||||
|
category_counts: dict[str, int] = {}
|
||||||
|
for node in nodes:
|
||||||
|
slug = node.get("type", "") if isinstance(node, dict) else ""
|
||||||
|
cat = slug_to_cat.get(slug, "other")
|
||||||
|
category_counts[cat] = category_counts.get(cat, 0) + 1
|
||||||
|
|
||||||
|
return NetworkDiagramListItem(
|
||||||
|
id=diagram.id,
|
||||||
|
name=diagram.name,
|
||||||
|
client_name=diagram.client_name,
|
||||||
|
description=diagram.description,
|
||||||
|
node_count=len(nodes),
|
||||||
|
category_counts=category_counts,
|
||||||
|
created_by=diagram.created_by,
|
||||||
|
created_at=diagram.created_at,
|
||||||
|
updated_at=diagram.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_available_slugs(account_id: UUID, db: AsyncSession) -> set[str]:
|
||||||
|
stmt = select(DeviceType.slug).where(
|
||||||
|
or_(
|
||||||
|
DeviceType.account_id == PLATFORM_ACCOUNT_ID,
|
||||||
|
DeviceType.account_id == account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
|
||||||
|
@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.account_id == current_user.account_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.account_id == current_user.account_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:
|
||||||
|
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
search_filter = f"%{escaped}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
NetworkDiagram.name.ilike(search_filter),
|
||||||
|
NetworkDiagram.client_name.ilike(search_filter),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Single query for custom device types so category_counts is accurate
|
||||||
|
dt_stmt = select(DeviceType.slug, DeviceType.category).where(
|
||||||
|
DeviceType.is_system.is_(False),
|
||||||
|
DeviceType.account_id == current_user.account_id,
|
||||||
|
)
|
||||||
|
dt_result = await db.execute(dt_stmt)
|
||||||
|
custom_slug_category = {row[0]: row[1] for row in dt_result.all()}
|
||||||
|
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
return [_diagram_to_list_item(r, custom_slug_category) 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(
|
||||||
|
account_id=current_user.account_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.account_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.account_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.account_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.account_id, db)
|
||||||
|
copy = NetworkDiagram(
|
||||||
|
account_id=current_user.account_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.account_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_slugs = await _get_available_slugs(current_user.account_id, db)
|
||||||
|
|
||||||
|
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(
|
||||||
|
account_id=current_user.account_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:
|
||||||
|
available_slugs_set = await _get_available_slugs(current_user.account_id, db)
|
||||||
|
available_slugs = list(available_slugs_set)
|
||||||
|
|
||||||
|
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")
|
||||||
@@ -24,6 +24,7 @@ from app.api.endpoints import (
|
|||||||
branding,
|
branding,
|
||||||
categories,
|
categories,
|
||||||
copilot,
|
copilot,
|
||||||
|
device_types,
|
||||||
feedback,
|
feedback,
|
||||||
flow_proposals,
|
flow_proposals,
|
||||||
flowpilot_analytics,
|
flowpilot_analytics,
|
||||||
@@ -32,6 +33,7 @@ from app.api.endpoints import (
|
|||||||
invite,
|
invite,
|
||||||
kb_accelerator,
|
kb_accelerator,
|
||||||
maintenance_schedules,
|
maintenance_schedules,
|
||||||
|
network_diagrams,
|
||||||
notifications,
|
notifications,
|
||||||
onboarding,
|
onboarding,
|
||||||
public_templates,
|
public_templates,
|
||||||
@@ -93,7 +95,6 @@ api_router.include_router(admin_settings.router)
|
|||||||
api_router.include_router(admin_categories.router)
|
api_router.include_router(admin_categories.router)
|
||||||
api_router.include_router(admin_survey.router)
|
api_router.include_router(admin_survey.router)
|
||||||
api_router.include_router(admin_gallery.router)
|
api_router.include_router(admin_gallery.router)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# User-facing endpoints — tenant context required
|
# User-facing endpoints — tenant context required
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -130,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(onboarding.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(branding.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(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
|
# 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_handoffs.queue_router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||||
@@ -142,3 +144,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(beta_feedback.router, dependencies=_tenant_deps)
|
||||||
api_router.include_router(session_branches.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(session_handoffs.router, dependencies=_tenant_deps)
|
||||||
|
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ class Settings(BaseSettings):
|
|||||||
"variable_inference": "fast",
|
"variable_inference": "fast",
|
||||||
"kb_convert": "standard",
|
"kb_convert": "standard",
|
||||||
"script_build": "standard",
|
"script_build": "standard",
|
||||||
|
"network_diagram_generate": "standard",
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_model_for_action(self, action_type: str) -> str:
|
def get_model_for_action(self, action_type: str) -> str:
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ from .session_handoff import SessionHandoff
|
|||||||
from .session_resolution_output import SessionResolutionOutput
|
from .session_resolution_output import SessionResolutionOutput
|
||||||
from .template_tree import TemplateTree
|
from .template_tree import TemplateTree
|
||||||
from .platform_step import PlatformStep
|
from .platform_step import PlatformStep
|
||||||
|
from .device_type import DeviceType
|
||||||
|
from .network_diagram import NetworkDiagram
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -126,4 +128,6 @@ __all__ = [
|
|||||||
"SessionResolutionOutput",
|
"SessionResolutionOutput",
|
||||||
"TemplateTree",
|
"TemplateTree",
|
||||||
"PlatformStep",
|
"PlatformStep",
|
||||||
|
"DeviceType",
|
||||||
|
"NetworkDiagram",
|
||||||
]
|
]
|
||||||
|
|||||||
47
backend/app/models/device_type.py
Normal file
47
backend/app/models/device_type.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Device type model for network diagrams."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceType(Base):
|
||||||
|
"""A device type for network diagram nodes (platform or account-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",
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="Platform account for system types, tenant account for 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)
|
||||||
|
)
|
||||||
53
backend/app/models/network_diagram.py
Normal file
53
backend/app/models/network_diagram.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"""Network diagram model."""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, 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 scoped to one account."""
|
||||||
|
__tablename__ = "network_diagrams"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
||||||
|
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
||||||
|
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
is_archived: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False,
|
||||||
|
)
|
||||||
|
created_by: Mapped[uuid.UUID | None] = 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["User | None"] = relationship("User", foreign_keys=[created_by])
|
||||||
37
backend/app/schemas/device_type.py
Normal file
37
backend/app/schemas/device_type.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""Pydantic schemas for device types."""
|
||||||
|
from datetime import datetime
|
||||||
|
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
|
||||||
|
account_id: UUID
|
||||||
|
sort_order: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
136
backend/app/schemas/network_diagram.py
Normal file
136
backend/app/schemas/network_diagram.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""Pydantic schemas for network diagrams."""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Position(BaseModel):
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
|
||||||
|
|
||||||
|
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: Position
|
||||||
|
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
|
||||||
|
routing: 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
|
||||||
|
account_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
|
||||||
|
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
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
|
||||||
151
backend/app/services/network_diagram_ai_service.py
Normal file
151
backend/app/services/network_diagram_ai_service.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""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,
|
||||||
|
Position,
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
try:
|
||||||
|
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=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"),
|
||||||
|
))
|
||||||
|
except KeyError as e:
|
||||||
|
logger.warning("AI response missing required field: %s", e)
|
||||||
|
raise ValueError(f"AI generated incomplete data (missing {e}), please try again")
|
||||||
|
|
||||||
|
return AIGenerateResponse(
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
suggestedName=data.get("suggestedName"),
|
||||||
|
notes=data.get("notes"),
|
||||||
|
)
|
||||||
96
backend/tests/test_network_diagrams.py
Normal file
96
backend/tests/test_network_diagrams.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.device_type import DeviceType
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.service_account import PLATFORM_ACCOUNT_ID
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_headers(client, email: str, password: str) -> dict[str, str]:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login/json",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
token = response.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_device_types_include_platform_and_account_custom(client, test_db, auth_headers, test_user):
|
||||||
|
result = await test_db.execute(select(User).where(User.email == test_user["email"]))
|
||||||
|
user = result.scalar_one()
|
||||||
|
|
||||||
|
test_db.add(
|
||||||
|
DeviceType(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
slug="platform-router",
|
||||||
|
label="Platform Router",
|
||||||
|
category="network",
|
||||||
|
is_system=True,
|
||||||
|
account_id=PLATFORM_ACCOUNT_ID,
|
||||||
|
sort_order=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
create_response = await client.post(
|
||||||
|
"/api/v1/device-types/",
|
||||||
|
json={
|
||||||
|
"slug": "tenant-appliance",
|
||||||
|
"label": "Tenant Appliance",
|
||||||
|
"category": "network",
|
||||||
|
"sort_order": 3,
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
assert create_response.json()["account_id"] == str(user.account_id)
|
||||||
|
|
||||||
|
list_response = await client.get("/api/v1/device-types/", headers=auth_headers)
|
||||||
|
assert list_response.status_code == 200
|
||||||
|
payload = list_response.json()
|
||||||
|
slugs = {item["slug"] for item in payload}
|
||||||
|
|
||||||
|
assert "platform-router" in slugs
|
||||||
|
assert "tenant-appliance" in slugs
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_network_diagrams_are_account_scoped(client, test_db, auth_headers, test_user):
|
||||||
|
other_user = {
|
||||||
|
"email": "other-network@example.com",
|
||||||
|
"password": "TestPassword123!",
|
||||||
|
"name": "Other Network User",
|
||||||
|
}
|
||||||
|
register_response = await client.post("/api/v1/auth/register", json=other_user)
|
||||||
|
assert register_response.status_code in (200, 201)
|
||||||
|
other_headers = await _login_headers(client, other_user["email"], other_user["password"])
|
||||||
|
|
||||||
|
owner_result = await test_db.execute(select(User).where(User.email == test_user["email"]))
|
||||||
|
owner = owner_result.scalar_one()
|
||||||
|
|
||||||
|
create_response = await client.post(
|
||||||
|
"/api/v1/network-diagrams/",
|
||||||
|
json={
|
||||||
|
"name": "HQ Core",
|
||||||
|
"client_name": "Acme",
|
||||||
|
"description": "Primary topology",
|
||||||
|
"nodes": [],
|
||||||
|
"edges": [],
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
diagram = create_response.json()
|
||||||
|
assert diagram["account_id"] == str(owner.account_id)
|
||||||
|
|
||||||
|
own_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=auth_headers)
|
||||||
|
assert own_get.status_code == 200
|
||||||
|
|
||||||
|
other_get = await client.get(f"/api/v1/network-diagrams/{diagram['id']}", headers=other_headers)
|
||||||
|
assert other_get.status_code == 404
|
||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -23,6 +23,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
@@ -5331,6 +5332,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-image": {
|
||||||
|
"version": "1.11.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||||
|
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"immer": "^11.1.3",
|
"immer": "^11.1.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
|||||||
BIN
frontend/public/images/hero_001.jpg
Normal file
BIN
frontend/public/images/hero_001.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 686 KiB |
23
frontend/src/api/deviceTypes.ts
Normal file
23
frontend/src/api/deviceTypes.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -35,3 +35,5 @@ export { betaFeedbackApi } from './betaFeedback'
|
|||||||
export { branchesApi } from './branches'
|
export { branchesApi } from './branches'
|
||||||
export { handoffsApi } from './handoffs'
|
export { handoffsApi } from './handoffs'
|
||||||
export { resolutionsApi } from './resolutions'
|
export { resolutionsApi } from './resolutions'
|
||||||
|
export { deviceTypesApi } from './deviceTypes'
|
||||||
|
export { networkDiagramsApi } from './networkDiagrams'
|
||||||
|
|||||||
63
frontend/src/api/networkDiagrams.ts
Normal file
63
frontend/src/api/networkDiagrams.ts
Normal file
@@ -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<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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
||||||
ListChecks, Download, BarChart3,
|
ListChecks, Download, BarChart3,
|
||||||
Settings, Pin, PinOff,
|
Settings, Pin, PinOff,
|
||||||
History, FileText,
|
History, FileText, Network,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
@@ -86,10 +86,11 @@ export function Sidebar() {
|
|||||||
{
|
{
|
||||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||||
badge: stats?.tree_counts.total || undefined,
|
badge: stats?.tree_counts.total || undefined,
|
||||||
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue'],
|
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'],
|
||||||
children: [
|
children: [
|
||||||
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
|
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
|
||||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
||||||
|
{ href: '/network-diagrams', label: 'Network Maps' },
|
||||||
{ href: '/step-library', label: 'Solutions Library' },
|
{ href: '/step-library', label: 'Solutions Library' },
|
||||||
{ href: '/review-queue', label: 'Review Queue' },
|
{ href: '/review-queue', label: 'Review Queue' },
|
||||||
],
|
],
|
||||||
@@ -134,6 +135,7 @@ export function Sidebar() {
|
|||||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
|
||||||
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
|
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
|
||||||
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
|
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
|
||||||
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
|
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
|
||||||
|
|||||||
232
frontend/src/components/network/CanvasEmptyPrompt.tsx
Normal file
232
frontend/src/components/network/CanvasEmptyPrompt.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react'
|
||||||
|
import { Sparkles, ArrowRight, PencilRuler, Wand2, X } from 'lucide-react'
|
||||||
|
import { networkDiagramsApi } from '@/api'
|
||||||
|
import type { AIGenerateResponse } from '@/types'
|
||||||
|
|
||||||
|
const EXAMPLE_PROMPTS = [
|
||||||
|
'Small office with firewall and core switch',
|
||||||
|
'Azure hybrid cloud with VPN gateway',
|
||||||
|
'Branch office connected to HQ via MPLS',
|
||||||
|
'Data center with redundant core switches',
|
||||||
|
'Remote workforce with Meraki and cloud apps',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CanvasEmptyPromptProps {
|
||||||
|
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CanvasEmptyPrompt({ onGenerate }: CanvasEmptyPromptProps) {
|
||||||
|
const [mode, setMode] = useState<'choice' | 'ai' | 'manual'>('choice')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const switchToManual = useCallback(() => {
|
||||||
|
if (loading) return
|
||||||
|
setMode('manual')
|
||||||
|
setError(null)
|
||||||
|
}, [loading])
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async (text?: string) => {
|
||||||
|
const desc = (text ?? description).trim()
|
||||||
|
if (!desc) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await networkDiagramsApi.aiGenerate({
|
||||||
|
description: desc,
|
||||||
|
mode: 'replace',
|
||||||
|
existingBounds: null,
|
||||||
|
})
|
||||||
|
onGenerate(result, 'replace')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Generation failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [description, onGenerate])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'manual') return
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
switchToManual()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [mode, switchToManual])
|
||||||
|
|
||||||
|
if (mode === 'manual') {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-6 z-10 flex justify-center px-6">
|
||||||
|
<div className="pointer-events-auto flex max-w-xl items-center gap-3 rounded-lg border border-default bg-card px-4 py-3 shadow-xl">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent">
|
||||||
|
<PencilRuler size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-heading">Manual mode is on</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Drag devices from the left panel onto the canvas, or reopen AI whenever you want.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('ai')}
|
||||||
|
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-default px-3 py-1 text-xs font-medium text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
<Sparkles size={12} />
|
||||||
|
Open AI Generator
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center bg-[rgba(10,14,20,0.42)] px-6"
|
||||||
|
onClick={event => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
switchToManual()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-auto relative w-full max-w-lg rounded-lg border border-default bg-card p-8 shadow-2xl">
|
||||||
|
<button
|
||||||
|
onClick={switchToManual}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label="Close AI prompt and build manually"
|
||||||
|
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full border border-default text-muted-foreground hover:border-hover hover:text-primary disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
{mode === 'choice' ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<div className="mb-2 flex items-center justify-center gap-2">
|
||||||
|
<Wand2 size={16} className="text-accent" />
|
||||||
|
<h2 className="font-heading text-base font-semibold text-heading">
|
||||||
|
Start a network map
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Generate a topology with AI or start with a blank canvas and build it manually.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground/80">
|
||||||
|
Press <span className="font-medium text-primary">Esc</span> or click outside to skip AI and start dragging devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('ai')}
|
||||||
|
className="rounded-lg border border-accent/40 bg-accent/10 p-4 text-left transition-colors hover:border-accent hover:bg-accent/15"
|
||||||
|
>
|
||||||
|
<div className="mb-3 inline-flex rounded-lg bg-accent/15 p-2 text-accent">
|
||||||
|
<Sparkles size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 text-sm font-semibold text-heading">Generate with AI</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Describe the environment and let AI lay out the first version for you.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={switchToManual}
|
||||||
|
className="rounded-lg border border-default bg-elevated/40 p-4 text-left transition-colors hover:border-accent hover:bg-elevated/60"
|
||||||
|
>
|
||||||
|
<div className="mb-3 inline-flex rounded-lg bg-primary/10 p-2 text-primary">
|
||||||
|
<PencilRuler size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 text-sm font-semibold text-heading">Build manually</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Close this prompt and use click-and-drag from the left toolbar to place devices on the canvas.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-5 text-center">
|
||||||
|
<div className="mb-2 flex items-center justify-center gap-2">
|
||||||
|
<Sparkles size={16} className="text-accent" />
|
||||||
|
<h2 className="font-heading text-base font-semibold text-heading">
|
||||||
|
Describe your network
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
AI will generate the topology in seconds, or you can go back and switch to manual creation.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[11px] text-muted-foreground/80">
|
||||||
|
Press <span className="font-medium text-primary">Esc</span>, click outside, or use the close button to build manually instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleGenerate()
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Small office with a firewall, core switch, 3 access points, a file server, and 20 workstations"
|
||||||
|
rows={3}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
className="w-full resize-none rounded-lg border border-default bg-input px-4 py-3 pb-7 text-sm text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="pointer-events-none absolute bottom-2 right-3 text-[10px] text-muted-foreground">
|
||||||
|
⌘↵ to generate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap gap-1.5">
|
||||||
|
{EXAMPLE_PROMPTS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => handleGenerate(p)}
|
||||||
|
disabled={loading}
|
||||||
|
className="rounded-full border border-default px-3 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:text-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="mb-3 text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={switchToManual}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 rounded-lg border border-default px-4 py-2.5 text-sm font-medium text-primary hover:border-accent hover:text-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Build Manually
|
||||||
|
</button>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-2 py-2.5">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||||
|
<span className="text-sm text-muted-foreground">Mapping your network…</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleGenerate()}
|
||||||
|
disabled={!description.trim()}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:bg-accent/90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
Generate Diagram
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/components/network/ContextMenu.tsx
Normal file
119
frontend/src/components/network/ContextMenu.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface MenuAction {
|
||||||
|
label: string
|
||||||
|
icon: React.ElementType
|
||||||
|
shortcut: string
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
dividerBefore?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
position: { x: number; y: number }
|
||||||
|
actions: MenuAction[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const clampedPosition = { ...position }
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const itemCount = actions.length
|
||||||
|
const dividerCount = actions.filter(a => a.dividerBefore).length
|
||||||
|
const menuWidth = 192
|
||||||
|
const menuHeight = itemCount * 36 + dividerCount * 9 + 8
|
||||||
|
if (clampedPosition.x + menuWidth > window.innerWidth) {
|
||||||
|
clampedPosition.x = window.innerWidth - menuWidth - 8
|
||||||
|
}
|
||||||
|
if (clampedPosition.y + menuHeight > window.innerHeight) {
|
||||||
|
clampedPosition.y = window.innerHeight - menuHeight - 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as HTMLElement)) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
const handleScroll = () => onClose()
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
document.addEventListener('scroll', handleScroll, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
document.removeEventListener('scroll', handleScroll, true)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-50 w-48 rounded-lg border border-default bg-card py-1 shadow-lg"
|
||||||
|
style={{ left: clampedPosition.x, top: clampedPosition.y }}
|
||||||
|
>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<div key={action.label}>
|
||||||
|
{action.dividerBefore && (
|
||||||
|
<div className="my-1 border-t border-default" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
action.onClick()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
disabled={action.disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated',
|
||||||
|
action.disabled && 'opacity-40 pointer-events-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<action.icon size={14} />
|
||||||
|
<span>{action.label}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground">{action.shortcut}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function getNodeMenuActions(handlers: {
|
||||||
|
onCopy: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onBringToFront: () => void
|
||||||
|
onSendToBack: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}): MenuAction[] {
|
||||||
|
return [
|
||||||
|
{ label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy },
|
||||||
|
{ label: 'Duplicate', icon: CopyPlus, shortcut: 'Ctrl+D', onClick: handlers.onDuplicate },
|
||||||
|
{ label: 'Bring to Front', icon: BringToFront, shortcut: ']', onClick: handlers.onBringToFront, dividerBefore: true },
|
||||||
|
{ label: 'Send to Back', icon: SendToBack, shortcut: '[', onClick: handlers.onSendToBack },
|
||||||
|
{ label: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete, dividerBefore: true },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function getCanvasMenuActions(handlers: {
|
||||||
|
onPaste: () => void
|
||||||
|
onSelectAll: () => void
|
||||||
|
onFitView: () => void
|
||||||
|
hasClipboard: boolean
|
||||||
|
}): MenuAction[] {
|
||||||
|
return [
|
||||||
|
{ label: 'Paste', icon: ClipboardPaste, shortcut: 'Ctrl+V', onClick: handlers.onPaste, disabled: !handlers.hasClipboard },
|
||||||
|
{ label: 'Select All', icon: BoxSelect, shortcut: 'Ctrl+A', onClick: handlers.onSelectAll },
|
||||||
|
{ label: 'Fit View', icon: Maximize2, shortcut: '⌘⇧F', onClick: handlers.onFitView },
|
||||||
|
]
|
||||||
|
}
|
||||||
167
frontend/src/components/network/DiagramHeader.tsx
Normal file
167
frontend/src/components/network/DiagramHeader.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
|
||||||
|
|
||||||
|
interface DiagramHeaderProps {
|
||||||
|
name: string
|
||||||
|
clientName: string | null
|
||||||
|
isDirty: boolean
|
||||||
|
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,
|
||||||
|
isDirty,
|
||||||
|
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])
|
||||||
|
|
||||||
|
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
|
||||||
|
// eslint-disable-next-line react-hooks/purity
|
||||||
|
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">
|
||||||
|
<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" />
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{clientName && (
|
||||||
|
<span className="rounded-full bg-elevated px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{clientName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{isDirty && !isSaving ? (
|
||||||
|
<span className="text-[10px] text-amber-400">Unsaved changes</span>
|
||||||
|
) : lastSavedAt ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{formatLastSaved()}</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/components/network/NetworkCanvas.tsx
Normal file
119
frontend/src/components/network/NetworkCanvas.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
BackgroundVariant,
|
||||||
|
type OnConnect,
|
||||||
|
type OnNodesChange,
|
||||||
|
type OnEdgesChange,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
|
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
||||||
|
import type { DeviceNodeData } from './nodes/DeviceNode'
|
||||||
|
|
||||||
|
interface NetworkCanvasProps {
|
||||||
|
nodes: Node[]
|
||||||
|
edges: Edge[]
|
||||||
|
onNodesChange: OnNodesChange
|
||||||
|
onEdgesChange: OnEdgesChange
|
||||||
|
onConnect: OnConnect
|
||||||
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
|
onEdgeSelect: (edgeId: string | null) => void
|
||||||
|
onDrop: (event: React.DragEvent) => void
|
||||||
|
onDragOver: (event: React.DragEvent) => void
|
||||||
|
onDragLeave?: (event: React.DragEvent) => void
|
||||||
|
isDragOver?: boolean
|
||||||
|
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
||||||
|
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
||||||
|
onPaneClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkCanvas({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
onNodesChange,
|
||||||
|
onEdgesChange,
|
||||||
|
onConnect,
|
||||||
|
onNodeSelect,
|
||||||
|
onEdgeSelect,
|
||||||
|
onDrop,
|
||||||
|
onDragOver,
|
||||||
|
onDragLeave,
|
||||||
|
isDragOver,
|
||||||
|
onNodeContextMenu,
|
||||||
|
onPaneContextMenu,
|
||||||
|
onPaneClick: onPaneClickProp,
|
||||||
|
}: NetworkCanvasProps) {
|
||||||
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||||
|
if (selectedNodes.length === 1) {
|
||||||
|
onNodeSelect(selectedNodes[0].id)
|
||||||
|
onEdgeSelect(null)
|
||||||
|
} else if (selectedEdges.length === 1) {
|
||||||
|
onEdgeSelect(selectedEdges[0].id)
|
||||||
|
onNodeSelect(null)
|
||||||
|
} else {
|
||||||
|
onNodeSelect(null)
|
||||||
|
onEdgeSelect(null)
|
||||||
|
}
|
||||||
|
}, [onNodeSelect, onEdgeSelect])
|
||||||
|
|
||||||
|
const handlePaneClick = useCallback(() => {
|
||||||
|
onNodeSelect(null)
|
||||||
|
onEdgeSelect(null)
|
||||||
|
onPaneClickProp?.()
|
||||||
|
}, [onNodeSelect, onEdgeSelect, onPaneClickProp])
|
||||||
|
|
||||||
|
const getNodeColor = useCallback((node: Node) => {
|
||||||
|
if (node.type === 'group') return 'var(--color-bg-elevated)'
|
||||||
|
const data = node.data as unknown as DeviceNodeData
|
||||||
|
return getDeviceRenderConfig(data?.deviceType || '', data?.category).color
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
onPaneClick={handlePaneClick}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
|
onPaneContextMenu={onPaneContextMenu}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
defaultEdgeOptions={{ type: 'connection' }}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
multiSelectionKeyCode="Shift"
|
||||||
|
snapToGrid={true}
|
||||||
|
snapGrid={[20, 20]}
|
||||||
|
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={getNodeColor}
|
||||||
|
maskColor="rgba(0,0,0,0.5)"
|
||||||
|
className="!border-default !bg-card"
|
||||||
|
position="bottom-right"
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
{isDragOver && (
|
||||||
|
<div className="pointer-events-none absolute inset-2 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-accent/30">
|
||||||
|
<span className="rounded-md bg-card/80 px-3 py-1.5 text-sm text-muted-foreground">
|
||||||
|
Drop to add
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
frontend/src/components/network/edges/ConnectionEdge.tsx
Normal file
71
frontend/src/components/network/edges/ConnectionEdge.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { BaseEdge, EdgeLabelRenderer, getStraightPath, getBezierPath, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
|
||||||
|
|
||||||
|
interface ConnectionEdgeData {
|
||||||
|
connectionType?: string
|
||||||
|
routing?: string | null
|
||||||
|
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 getEdgePath(routing: string | null | undefined, props: EdgeProps) {
|
||||||
|
const base = {
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
targetPosition: props.targetPosition,
|
||||||
|
}
|
||||||
|
if (routing === 'curved') return getBezierPath(base)
|
||||||
|
if (routing === 'step') return getSmoothStepPath(base)
|
||||||
|
return getStraightPath(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionEdgeComponent(props: EdgeProps) {
|
||||||
|
const edgeData = props.data as ConnectionEdgeData | undefined
|
||||||
|
const connectionType = edgeData?.connectionType || 'ethernet'
|
||||||
|
const style = CONNECTION_STYLES[connectionType] || DEFAULT_STYLE
|
||||||
|
|
||||||
|
const [edgePath, labelX, labelY] = getEdgePath(edgeData?.routing, props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
path={edgePath}
|
||||||
|
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(${labelX}px, ${labelY}px)`,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.label}
|
||||||
|
</div>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectionEdge = memo(ConnectionEdgeComponent)
|
||||||
7
frontend/src/components/network/edges/edgeTypes.ts
Normal file
7
frontend/src/components/network/edges/edgeTypes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { ConnectionEdge } from './ConnectionEdge'
|
||||||
|
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
|
||||||
|
|
||||||
|
export const edgeTypes = {
|
||||||
|
connection: ConnectionEdge,
|
||||||
|
animated: AnimatedSvgEdge,
|
||||||
|
}
|
||||||
252
frontend/src/components/network/hooks/useCanvasShortcuts.ts
Normal file
252
frontend/src/components/network/hooks/useCanvasShortcuts.ts
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useReactFlow, type Node, type Edge } from '@xyflow/react'
|
||||||
|
|
||||||
|
interface ClipboardData {
|
||||||
|
nodes: Array<{
|
||||||
|
type: string
|
||||||
|
data: Record<string, unknown>
|
||||||
|
style?: React.CSSProperties
|
||||||
|
relativePosition: { x: number; y: number }
|
||||||
|
}>
|
||||||
|
edges: Array<{
|
||||||
|
sourceIndex: number
|
||||||
|
targetIndex: number
|
||||||
|
type?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
label?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInputFocused(): boolean {
|
||||||
|
const tag = document.activeElement?.tagName
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCanvasShortcuts({
|
||||||
|
nodes: _nodes, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
edges,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
setIsDirty,
|
||||||
|
canvasRef,
|
||||||
|
}: {
|
||||||
|
nodes: Node[]
|
||||||
|
edges: Edge[]
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
|
||||||
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
||||||
|
setIsDirty: (dirty: boolean) => void
|
||||||
|
canvasRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
}) {
|
||||||
|
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||||
|
const clipboardRef = useRef<ClipboardData | null>(null)
|
||||||
|
|
||||||
|
const getSelectedNodes = useCallback((): Node[] => {
|
||||||
|
return getNodes().filter(n => n.selected)
|
||||||
|
}, [getNodes])
|
||||||
|
|
||||||
|
const copyNodes = useCallback(() => {
|
||||||
|
const selected = getSelectedNodes()
|
||||||
|
if (selected.length === 0) return
|
||||||
|
|
||||||
|
const centroid = {
|
||||||
|
x: selected.reduce((sum, n) => sum + n.position.x, 0) / selected.length,
|
||||||
|
y: selected.reduce((sum, n) => sum + n.position.y, 0) / selected.length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = new Set(selected.map(n => n.id))
|
||||||
|
|
||||||
|
const clipNodes = selected.map(n => ({
|
||||||
|
type: n.type || 'device',
|
||||||
|
data: structuredClone(n.data),
|
||||||
|
style: n.style ? { ...n.style } : undefined,
|
||||||
|
relativePosition: {
|
||||||
|
x: n.position.x - centroid.x,
|
||||||
|
y: n.position.y - centroid.y,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const selectedList = selected.map(n => n.id)
|
||||||
|
const clipEdges = edges
|
||||||
|
.filter(e => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||||
|
.map(e => ({
|
||||||
|
sourceIndex: selectedList.indexOf(e.source),
|
||||||
|
targetIndex: selectedList.indexOf(e.target),
|
||||||
|
type: e.type,
|
||||||
|
data: e.data ? structuredClone(e.data) as Record<string, unknown> : undefined,
|
||||||
|
label: typeof e.label === 'string' ? e.label : undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
clipboardRef.current = { nodes: clipNodes, edges: clipEdges }
|
||||||
|
}, [getSelectedNodes, edges])
|
||||||
|
|
||||||
|
const pasteNodes = useCallback(() => {
|
||||||
|
const clipboard = clipboardRef.current
|
||||||
|
if (!clipboard || clipboard.nodes.length === 0) return
|
||||||
|
|
||||||
|
const canvasEl = canvasRef.current
|
||||||
|
if (!canvasEl) return
|
||||||
|
const rect = canvasEl.getBoundingClientRect()
|
||||||
|
const center = screenToFlowPosition({
|
||||||
|
x: rect.left + rect.width / 2,
|
||||||
|
y: rect.top + rect.height / 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const newNodeIds: string[] = []
|
||||||
|
const newNodes: Node[] = clipboard.nodes.map(cn => {
|
||||||
|
const prefix = cn.type === 'group' ? 'group' : 'device'
|
||||||
|
const id = generateId(prefix)
|
||||||
|
newNodeIds.push(id)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: cn.type,
|
||||||
|
position: {
|
||||||
|
x: center.x + cn.relativePosition.x,
|
||||||
|
y: center.y + cn.relativePosition.y,
|
||||||
|
},
|
||||||
|
data: structuredClone(cn.data) as Record<string, unknown>,
|
||||||
|
style: cn.style ? { ...cn.style } : undefined,
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const newEdges: Edge[] = clipboard.edges.map(ce => ({
|
||||||
|
id: generateId('edge'),
|
||||||
|
source: newNodeIds[ce.sourceIndex],
|
||||||
|
target: newNodeIds[ce.targetIndex],
|
||||||
|
type: ce.type,
|
||||||
|
data: ce.data ? structuredClone(ce.data) as Record<string, unknown> : undefined,
|
||||||
|
label: ce.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setNodes(nds => [
|
||||||
|
...nds.map(n => ({ ...n, selected: false })),
|
||||||
|
...newNodes,
|
||||||
|
])
|
||||||
|
setEdges(eds => [...eds, ...newEdges])
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [canvasRef, screenToFlowPosition, setNodes, setEdges, setIsDirty])
|
||||||
|
|
||||||
|
const duplicateNodes = useCallback(() => {
|
||||||
|
const selected = getSelectedNodes()
|
||||||
|
if (selected.length === 0) return
|
||||||
|
|
||||||
|
const selectedIds = new Set(selected.map(n => n.id))
|
||||||
|
const idMap = new Map<string, string>()
|
||||||
|
|
||||||
|
const newNodes: Node[] = selected.map(n => {
|
||||||
|
const prefix = n.type === 'group' ? 'group' : 'device'
|
||||||
|
const newId = generateId(prefix)
|
||||||
|
idMap.set(n.id, newId)
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
type: n.type,
|
||||||
|
position: { x: n.position.x + 30, y: n.position.y + 30 },
|
||||||
|
data: structuredClone(n.data) as Record<string, unknown>,
|
||||||
|
style: n.style ? { ...n.style } : undefined,
|
||||||
|
selected: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const newEdges: Edge[] = edges
|
||||||
|
.filter(e => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||||
|
.map(e => ({
|
||||||
|
id: generateId('edge'),
|
||||||
|
source: idMap.get(e.source)!,
|
||||||
|
target: idMap.get(e.target)!,
|
||||||
|
type: e.type,
|
||||||
|
data: e.data ? structuredClone(e.data) as Record<string, unknown> : undefined,
|
||||||
|
label: e.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setNodes(nds => [
|
||||||
|
...nds.map(n => ({ ...n, selected: false })),
|
||||||
|
...newNodes,
|
||||||
|
])
|
||||||
|
setEdges(eds => [...eds, ...newEdges])
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [getSelectedNodes, edges, setNodes, setEdges, setIsDirty])
|
||||||
|
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
rfSetNodes(nds => nds.map(n => ({ ...n, selected: true })))
|
||||||
|
}, [rfSetNodes])
|
||||||
|
|
||||||
|
const deleteSelected = useCallback(() => {
|
||||||
|
const selected = getSelectedNodes()
|
||||||
|
if (selected.length === 0) return
|
||||||
|
const selectedIds = new Set(selected.map(n => n.id))
|
||||||
|
setNodes(nds => nds.filter(n => !selectedIds.has(n.id)))
|
||||||
|
setEdges(eds => eds.filter(e => !selectedIds.has(e.source) && !selectedIds.has(e.target)))
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [getSelectedNodes, setNodes, setEdges, setIsDirty])
|
||||||
|
|
||||||
|
const bringSelectedToFront = useCallback(() => {
|
||||||
|
const selected = getSelectedNodes()
|
||||||
|
if (!selected.length) return
|
||||||
|
const selectedIds = new Set(selected.map(n => n.id))
|
||||||
|
setNodes(nds => {
|
||||||
|
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
|
||||||
|
return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: maxZ + 1 } : n)
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [getSelectedNodes, setNodes, setIsDirty])
|
||||||
|
|
||||||
|
const sendSelectedToBack = useCallback(() => {
|
||||||
|
const selected = getSelectedNodes()
|
||||||
|
if (!selected.length) return
|
||||||
|
const selectedIds = new Set(selected.map(n => n.id))
|
||||||
|
setNodes(nds => {
|
||||||
|
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
|
||||||
|
return nds.map(n => selectedIds.has(n.id) ? { ...n, zIndex: minZ - 1 } : n)
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [getSelectedNodes, setNodes, setIsDirty])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (isInputFocused()) return
|
||||||
|
|
||||||
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
|
|
||||||
|
if (ctrl && e.key === 'c') {
|
||||||
|
e.preventDefault()
|
||||||
|
copyNodes()
|
||||||
|
} else if (ctrl && e.key === 'v') {
|
||||||
|
e.preventDefault()
|
||||||
|
pasteNodes()
|
||||||
|
} else if (ctrl && e.key === 'd') {
|
||||||
|
e.preventDefault()
|
||||||
|
duplicateNodes()
|
||||||
|
} else if (ctrl && e.key === 'a') {
|
||||||
|
e.preventDefault()
|
||||||
|
selectAll()
|
||||||
|
} else if (ctrl && e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
||||||
|
e.preventDefault()
|
||||||
|
fitView({ padding: 0.2 })
|
||||||
|
} else if (e.key === ']' && !ctrl) {
|
||||||
|
e.preventDefault()
|
||||||
|
bringSelectedToFront()
|
||||||
|
} else if (e.key === '[' && !ctrl) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendSelectedToBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack])
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyNodes,
|
||||||
|
pasteNodes,
|
||||||
|
duplicateNodes,
|
||||||
|
selectAll,
|
||||||
|
deleteSelected,
|
||||||
|
bringSelectedToFront,
|
||||||
|
sendSelectedToBack,
|
||||||
|
hasClipboard: () => clipboardRef.current !== null && clipboardRef.current.nodes.length > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
106
frontend/src/components/network/nodes/DeviceNode.tsx
Normal file
106
frontend/src/components/network/nodes/DeviceNode.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
|
||||||
|
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
|
||||||
|
import { BaseHandle } from '../ui/base-handle'
|
||||||
|
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
|
||||||
|
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
|
||||||
|
import { getDeviceRenderConfig } from './deviceRegistry'
|
||||||
|
import type { DeviceProperties } from '@/types'
|
||||||
|
|
||||||
|
export interface DeviceNodeData {
|
||||||
|
label: string
|
||||||
|
deviceType: string
|
||||||
|
category?: string
|
||||||
|
properties: DeviceProperties
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipRow({ label, value }: { label: string; value: string | null | undefined }) {
|
||||||
|
if (!value) return null
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-xs font-mono text-primary">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_DEFAULT = 120 // default square side in px
|
||||||
|
const NODE_MIN = 80 // minimum square side in px
|
||||||
|
const NODE_MAX = 280 // maximum square side in px
|
||||||
|
|
||||||
|
function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
|
||||||
|
const nodeData = data as unknown as DeviceNodeData
|
||||||
|
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
|
||||||
|
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
||||||
|
const ip = nodeData.properties?.ip
|
||||||
|
const props = nodeData.properties || {}
|
||||||
|
|
||||||
|
// Use the shorter dimension so content never overflows a non-square node
|
||||||
|
const size = Math.min(width ?? NODE_DEFAULT, height ?? NODE_DEFAULT)
|
||||||
|
const scale = size / NODE_DEFAULT
|
||||||
|
|
||||||
|
// Icon: 28px at default, clamped to [14, 72]
|
||||||
|
const iconPx = Math.round(Math.max(14, Math.min(72, scale * 28)))
|
||||||
|
// Label font: 11px at default, clamped to [9, 20]
|
||||||
|
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
|
||||||
|
// IP font: 9px at default, clamped to [8, 16]
|
||||||
|
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
|
||||||
|
|
||||||
|
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={NODE_MIN}
|
||||||
|
minHeight={NODE_MIN}
|
||||||
|
maxWidth={NODE_MAX}
|
||||||
|
maxHeight={NODE_MAX}
|
||||||
|
keepAspectRatio
|
||||||
|
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
||||||
|
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
||||||
|
/>
|
||||||
|
<NodeStatusIndicator status={status}>
|
||||||
|
<NodeTooltip>
|
||||||
|
<NodeTooltipTrigger>
|
||||||
|
<BaseNode className="w-full h-full group flex flex-col items-center justify-center">
|
||||||
|
<BaseNodeHeader className="flex-col gap-1 items-center py-2 px-2">
|
||||||
|
<Icon size={iconPx} style={{ color }} />
|
||||||
|
<BaseNodeHeaderTitle className="text-center leading-tight" style={{ fontSize: labelPx }}>
|
||||||
|
{nodeData.label}
|
||||||
|
</BaseNodeHeaderTitle>
|
||||||
|
</BaseNodeHeader>
|
||||||
|
{ip && (
|
||||||
|
<BaseNodeContent className="items-center pt-0 pb-1">
|
||||||
|
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
|
||||||
|
</BaseNodeContent>
|
||||||
|
)}
|
||||||
|
<BaseHandle type="target" position={Position.Top} />
|
||||||
|
<BaseHandle type="source" position={Position.Bottom} />
|
||||||
|
<BaseHandle type="target" position={Position.Left} id="left" />
|
||||||
|
<BaseHandle type="source" position={Position.Right} id="right" />
|
||||||
|
</BaseNode>
|
||||||
|
</NodeTooltipTrigger>
|
||||||
|
{hasTooltipContent && (
|
||||||
|
<NodeTooltipContent position={Position.Top}>
|
||||||
|
<div className="flex flex-col gap-1 min-w-[140px]">
|
||||||
|
<TooltipRow label="Host" value={props.hostname} />
|
||||||
|
<TooltipRow label="IP" value={props.ip} />
|
||||||
|
{(props.vendor || props.model) && (
|
||||||
|
<TooltipRow label="HW" value={[props.vendor, props.model].filter(Boolean).join(' ')} />
|
||||||
|
)}
|
||||||
|
<TooltipRow label="Role" value={props.role} />
|
||||||
|
{props.notes && (
|
||||||
|
<TooltipRow label="Notes" value={props.notes.length > 100 ? props.notes.slice(0, 100) + '...' : props.notes} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</NodeTooltipContent>
|
||||||
|
)}
|
||||||
|
</NodeTooltip>
|
||||||
|
</NodeStatusIndicator>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeviceNode = memo(DeviceNodeComponent)
|
||||||
113
frontend/src/components/network/nodes/deviceRegistry.ts
Normal file
113
frontend/src/components/network/nodes/deviceRegistry.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Router, Network, BrickWallFire, Wifi, Server, Monitor, Boxes, Package, Cloud,
|
||||||
|
Printer, Smartphone, HardDrive, Gauge, Database, CloudCog,
|
||||||
|
Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical,
|
||||||
|
Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export interface DeviceRenderConfig {
|
||||||
|
icon: LucideIcon
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category-semantic color palette — each color carries meaning:
|
||||||
|
// Network (blue) — backbone connectivity layer
|
||||||
|
// Security (orange) — critical/protective elements
|
||||||
|
// Compute (emerald)— running workloads and VMs
|
||||||
|
// Endpoint (amber) — user-facing devices
|
||||||
|
// Storage (violet) — data at rest
|
||||||
|
// Cloud (cyan) — external/internet-connected
|
||||||
|
// Infra (steel) — physical/passive hardware
|
||||||
|
export const NETWORK_COLOR = '#60a5fa'
|
||||||
|
export const SECURITY_COLOR = '#f87171'
|
||||||
|
export const COMPUTE_COLOR = '#34d399'
|
||||||
|
export const ENDPOINT_COLOR = '#fbbf24'
|
||||||
|
export const STORAGE_COLOR = '#a78bfa'
|
||||||
|
export const CLOUD_COLOR = '#67e8f9'
|
||||||
|
export const INFRA_COLOR = '#94a3b8'
|
||||||
|
|
||||||
|
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||||
|
// Network layer
|
||||||
|
'router': { icon: Router, color: NETWORK_COLOR },
|
||||||
|
'switch': { icon: Network, color: NETWORK_COLOR },
|
||||||
|
'access-point': { icon: Wifi, color: NETWORK_COLOR },
|
||||||
|
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
|
||||||
|
|
||||||
|
// Security
|
||||||
|
'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
|
||||||
|
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
|
||||||
|
|
||||||
|
// Compute
|
||||||
|
'server': { icon: Server, color: COMPUTE_COLOR },
|
||||||
|
'vm': { icon: Boxes, color: COMPUTE_COLOR },
|
||||||
|
'container': { icon: Package, color: COMPUTE_COLOR },
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
'nas': { icon: Database, color: STORAGE_COLOR },
|
||||||
|
'san': { icon: HardDrive, color: STORAGE_COLOR },
|
||||||
|
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
|
||||||
|
|
||||||
|
// Cloud / Internet
|
||||||
|
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
|
'aws': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
|
'azure': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
|
'gcp': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
|
'isp': { icon: Globe, color: CLOUD_COLOR },
|
||||||
|
|
||||||
|
// Endpoints
|
||||||
|
'workstation': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||||
|
'laptop': { icon: Laptop, color: ENDPOINT_COLOR },
|
||||||
|
'tablet': { icon: Tablet, color: ENDPOINT_COLOR },
|
||||||
|
'phone': { icon: Smartphone, color: ENDPOINT_COLOR },
|
||||||
|
'printer': { icon: Printer, color: ENDPOINT_COLOR },
|
||||||
|
|
||||||
|
// Infrastructure / physical
|
||||||
|
'ups': { icon: BatteryCharging, color: INFRA_COLOR },
|
||||||
|
'pdu': { icon: PlugZap, color: INFRA_COLOR },
|
||||||
|
'rack': { icon: RectangleVertical, color: INFRA_COLOR },
|
||||||
|
'patch-panel': { icon: Cable, color: INFRA_COLOR },
|
||||||
|
'camera': { icon: Camera, color: INFRA_COLOR },
|
||||||
|
'nvr': { icon: Video, color: INFRA_COLOR },
|
||||||
|
'iot': { icon: Radio, color: INFRA_COLOR },
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
||||||
|
'network': { icon: Router, color: NETWORK_COLOR },
|
||||||
|
'compute': { icon: Server, color: COMPUTE_COLOR },
|
||||||
|
'storage': { icon: Database, color: STORAGE_COLOR },
|
||||||
|
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
|
'endpoint': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||||
|
'infrastructure': { icon: PlugZap, color: INFRA_COLOR },
|
||||||
|
'security': { icon: BrickWallFire, color: SECURITY_COLOR },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR }
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
'network': 'Network',
|
||||||
|
'compute': 'Compute',
|
||||||
|
'storage': 'Storage',
|
||||||
|
'cloud': 'Cloud',
|
||||||
|
'endpoint': 'Endpoints',
|
||||||
|
'infrastructure': 'Infrastructure',
|
||||||
|
'security': 'Security',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
'network': NETWORK_COLOR,
|
||||||
|
'compute': COMPUTE_COLOR,
|
||||||
|
'storage': STORAGE_COLOR,
|
||||||
|
'cloud': CLOUD_COLOR,
|
||||||
|
'endpoint': ENDPOINT_COLOR,
|
||||||
|
'infrastructure': INFRA_COLOR,
|
||||||
|
'security': SECURITY_COLOR,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security']
|
||||||
7
frontend/src/components/network/nodes/nodeTypes.ts
Normal file
7
frontend/src/components/network/nodes/nodeTypes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { DeviceNode } from './DeviceNode'
|
||||||
|
import { GroupNode } from '../ui/labeled-group-node'
|
||||||
|
|
||||||
|
export const nodeTypes = {
|
||||||
|
device: DeviceNode,
|
||||||
|
group: GroupNode,
|
||||||
|
}
|
||||||
168
frontend/src/components/network/panels/AIAssistPanel.tsx
Normal file
168
frontend/src/components/network/panels/AIAssistPanel.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
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 [replaceConfirm, setReplaceConfirm] = useState(false)
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!description.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setReplaceConfirm(false)
|
||||||
|
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])
|
||||||
|
|
||||||
|
// Reset confirm state when mode changes or panel collapses
|
||||||
|
const handleModeChange = (newMode: 'replace' | 'merge') => {
|
||||||
|
setMode(newMode)
|
||||||
|
setReplaceConfirm(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsReplaceConfirm = mode === 'replace' && hasNodes
|
||||||
|
|
||||||
|
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); setReplaceConfirm(false) }}
|
||||||
|
className="text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleModeChange('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={() => handleModeChange('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>
|
||||||
|
|
||||||
|
{needsReplaceConfirm && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 && <p className="text-[11px] text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent border-t-transparent" />
|
||||||
|
<span className="text-xs text-muted-foreground">Generating your network diagram…</span>
|
||||||
|
</div>
|
||||||
|
) : needsReplaceConfirm && !replaceConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setReplaceConfirm(true)}
|
||||||
|
disabled={!description.trim()}
|
||||||
|
className="rounded border border-yellow-500/40 bg-yellow-500/10 px-4 py-2 text-xs font-medium text-yellow-400 hover:bg-yellow-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Replace Diagram…
|
||||||
|
</button>
|
||||||
|
) : needsReplaceConfirm && replaceConfirm ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setReplaceConfirm(false)}
|
||||||
|
className="flex-1 rounded border border-default px-3 py-2 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!description.trim()}
|
||||||
|
className="flex-1 rounded bg-red-500/20 px-3 py-2 text-xs font-medium text-red-400 hover:bg-red-500/30 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Yes, Replace
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
227
frontend/src/components/network/panels/DeviceToolbar.tsx
Normal file
227
frontend/src/components/network/panels/DeviceToolbar.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
|
import { Search, Plus, ChevronDown, ChevronRight, X, LayoutGrid, GripVertical, Globe } from 'lucide-react'
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||||
|
{CATEGORY_ORDER.map(cat => {
|
||||||
|
const items = filteredByCategory[cat] || []
|
||||||
|
const isCloud = cat === 'cloud'
|
||||||
|
const ispMatchesSearch = !search || 'isp'.includes(search.toLowerCase()) || 'internet service provider'.includes(search.toLowerCase())
|
||||||
|
const showIsp = isCloud && ispMatchesSearch
|
||||||
|
if (!items.length && !showIsp) return null
|
||||||
|
const collapsed = collapsedCategories.has(cat)
|
||||||
|
const totalCount = items.length + (showIsp ? 1 : 0)
|
||||||
|
|
||||||
|
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">{totalCount}</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 active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||||
|
<Icon size={14} style={{ color }} />
|
||||||
|
<span>{dt.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{showIsp && (
|
||||||
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/reactflow-device', JSON.stringify({
|
||||||
|
slug: 'isp',
|
||||||
|
label: 'ISP',
|
||||||
|
category: 'cloud',
|
||||||
|
}))
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}}
|
||||||
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||||
|
<Globe size={14} style={{ color: 'var(--color-accent)' }} />
|
||||||
|
<span>ISP</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grouping section */}
|
||||||
|
<div className="mb-1 mt-2 border-t border-default pt-2">
|
||||||
|
<div className="flex items-center gap-1 px-1 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Grouping
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{[
|
||||||
|
{ slug: 'subnet', label: 'Subnet' },
|
||||||
|
{ slug: 'vlan', label: 'VLAN' },
|
||||||
|
{ slug: 'site', label: 'Site' },
|
||||||
|
{ slug: 'dmz', label: 'DMZ' },
|
||||||
|
].map(item => (
|
||||||
|
<div
|
||||||
|
key={item.slug}
|
||||||
|
draggable
|
||||||
|
onDragStart={e => {
|
||||||
|
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}}
|
||||||
|
className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
<GripVertical size={12} className="shrink-0 text-muted-foreground/50" />
|
||||||
|
<LayoutGrid size={14} className="text-muted-foreground" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
412
frontend/src/components/network/panels/PropertiesPanel.tsx
Normal file
412
frontend/src/components/network/panels/PropertiesPanel.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import { useCallback, useState, useEffect } from 'react'
|
||||||
|
import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } 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
|
||||||
|
onEdgeTypeChange: (edgeId: string, edgeType: string) => void
|
||||||
|
onBringToFront: (nodeId: string) => void
|
||||||
|
onSendToBack: (nodeId: string) => void
|
||||||
|
onDeleteNode: (nodeId: string) => void
|
||||||
|
onDeleteEdge: (edgeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<NodeStatus, { color: string; label: string }> = {
|
||||||
|
online: { color: '#34d399', label: 'Online' },
|
||||||
|
offline: { color: '#f87171', label: 'Offline' },
|
||||||
|
degraded: { color: '#fbbf24', label: 'Degraded' },
|
||||||
|
unknown: { color: '#94a3b8', label: 'Unknown' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = Object.keys(STATUS_CONFIG) as NodeStatus[]
|
||||||
|
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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionDivider({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<span className="whitespace-nowrap text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 border-t border-default" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PropertiesPanel({
|
||||||
|
selectedNode,
|
||||||
|
selectedEdge,
|
||||||
|
onNodeUpdate,
|
||||||
|
onEdgeUpdate,
|
||||||
|
onEdgeTypeChange,
|
||||||
|
onBringToFront,
|
||||||
|
onSendToBack,
|
||||||
|
onDeleteNode,
|
||||||
|
onDeleteEdge,
|
||||||
|
}: PropertiesPanelProps) {
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
// Reset confirm state whenever the selection changes
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { setDeleteConfirm(false) }, [selectedNode?.id, selectedEdge?.id])
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
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>
|
||||||
|
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
|
||||||
|
Hover a device to preview its info
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>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 className="flex flex-col gap-1">
|
||||||
|
<FieldLabel>Line Style</FieldLabel>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{([
|
||||||
|
{ value: null, icon: Minus, label: 'Straight' },
|
||||||
|
{ value: 'curved', icon: Spline, label: 'Curved' },
|
||||||
|
{ value: 'step', icon: GitBranch, label: 'Step' },
|
||||||
|
] as const).map(({ value, icon: Icon, label }) => {
|
||||||
|
const routing = (edgeData.routing as string | null | undefined) ?? null
|
||||||
|
const active = routing === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
title={label}
|
||||||
|
onClick={() => onEdgeUpdate(selectedEdge.id, { routing: value })}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-center gap-1 rounded border py-1.5 text-[10px] transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-accent bg-accent/10 text-accent'
|
||||||
|
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={12} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FieldLabel>Show Traffic</FieldLabel>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newType = selectedEdge.type === 'animated' ? 'connection' : 'animated'
|
||||||
|
onEdgeTypeChange(selectedEdge.id, newType)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative h-5 w-9 rounded-full transition-colors',
|
||||||
|
selectedEdge.type === 'animated' ? 'bg-accent' : 'bg-elevated',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white transition-transform',
|
||||||
|
selectedEdge.type === 'animated' && 'translate-x-4',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-default p-3">
|
||||||
|
{deleteConfirm ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-center text-[10px] text-muted-foreground">Delete this connection?</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(false)}
|
||||||
|
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteEdge(selectedEdge.id)}
|
||||||
|
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeData = selectedNode!.data as unknown as DeviceNodeData
|
||||||
|
const props = nodeData.properties || {} as DeviceProperties
|
||||||
|
const currentStatus = (props.status || 'unknown') as NodeStatus
|
||||||
|
|
||||||
|
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">
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<FieldLabel>Name</FieldLabel>
|
||||||
|
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layering */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<FieldLabel>Layer</FieldLabel>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onBringToFront(selectedNode!.id)}
|
||||||
|
title="Bring to Front ]"
|
||||||
|
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
|
||||||
|
>
|
||||||
|
<BringToFront size={12} />
|
||||||
|
Bring Front
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSendToBack(selectedNode!.id)}
|
||||||
|
title="Send to Back ["
|
||||||
|
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-default px-2 py-1.5 text-[10px] text-muted-foreground hover:border-hover hover:text-primary"
|
||||||
|
>
|
||||||
|
<SendToBack size={12} />
|
||||||
|
Send Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badge grid */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<FieldLabel>Status</FieldLabel>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{STATUS_OPTIONS.map(opt => {
|
||||||
|
const { color, label } = STATUS_CONFIG[opt]
|
||||||
|
const active = currentStatus === opt
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt}
|
||||||
|
onClick={() => handlePropertyChange('status', opt)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-1.5 rounded border py-1.5 text-[10px] font-medium transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-transparent text-white'
|
||||||
|
: 'border-default text-muted-foreground hover:border-hover hover:text-primary',
|
||||||
|
)}
|
||||||
|
style={active ? { backgroundColor: color } : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: active ? 'rgba(255,255,255,0.8)' : color }}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Network section */}
|
||||||
|
<SectionDivider label="Network" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<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>VLAN</FieldLabel>
|
||||||
|
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" mono />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hardware section */}
|
||||||
|
<SectionDivider label="Hardware" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<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>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>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<SectionDivider label="Notes" />
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<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">
|
||||||
|
{deleteConfirm ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<p className="text-center text-[10px] text-muted-foreground">Delete this device?</p>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(false)}
|
||||||
|
className="flex-1 rounded border border-default px-2 py-1.5 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteNode(selectedNode!.id)}
|
||||||
|
className="flex-1 rounded bg-red-500/20 px-2 py-1.5 text-xs font-medium text-red-400 hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
frontend/src/components/network/ui/animated-svg-edge.tsx
Normal file
131
frontend/src/components/network/ui/animated-svg-edge.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import {
|
||||||
|
BaseEdge,
|
||||||
|
getSmoothStepPath,
|
||||||
|
getStraightPath,
|
||||||
|
getBezierPath,
|
||||||
|
type EdgeProps,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
|
||||||
|
interface AnimatedEdgeData {
|
||||||
|
connectionType?: string
|
||||||
|
duration?: number
|
||||||
|
direction?: 'forward' | 'reverse' | 'alternate' | 'alternate-reverse'
|
||||||
|
path?: 'bezier' | 'smoothstep' | 'step' | 'straight'
|
||||||
|
repeat?: number | 'indefinite'
|
||||||
|
shape?: 'circle' | 'package'
|
||||||
|
speed?: string | null
|
||||||
|
notes?: string | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONNECTION_COLORS: Record<string, string> = {
|
||||||
|
ethernet: '#60a5fa',
|
||||||
|
fiber: '#34d399',
|
||||||
|
wifi: '#a78bfa',
|
||||||
|
vpn: '#eab308',
|
||||||
|
vlan: '#848b9b',
|
||||||
|
wan: '#f87171',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_COLOR = '#848b9b'
|
||||||
|
|
||||||
|
function getPath(
|
||||||
|
props: EdgeProps,
|
||||||
|
pathType: string,
|
||||||
|
): [string, number, number] {
|
||||||
|
const params = {
|
||||||
|
sourceX: props.sourceX,
|
||||||
|
sourceY: props.sourceY,
|
||||||
|
sourcePosition: props.sourcePosition,
|
||||||
|
targetX: props.targetX,
|
||||||
|
targetY: props.targetY,
|
||||||
|
targetPosition: props.targetPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (pathType) {
|
||||||
|
case 'bezier': {
|
||||||
|
const [path, labelX, labelY] = getBezierPath(params)
|
||||||
|
return [path, labelX, labelY]
|
||||||
|
}
|
||||||
|
case 'straight': {
|
||||||
|
const [path, labelX, labelY] = getStraightPath(params)
|
||||||
|
return [path, labelX, labelY]
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
const [path, labelX, labelY] = getSmoothStepPath(params)
|
||||||
|
return [path, labelX, labelY]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnimateMotionProps(data: AnimatedEdgeData) {
|
||||||
|
const duration = data.duration ?? 2
|
||||||
|
const direction = data.direction ?? 'forward'
|
||||||
|
const repeat = data.repeat ?? 'indefinite'
|
||||||
|
|
||||||
|
const keyPoints: Record<string, string> = {
|
||||||
|
forward: '0;1',
|
||||||
|
reverse: '1;0',
|
||||||
|
alternate: '0;1',
|
||||||
|
'alternate-reverse': '1;0',
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dur: `${duration}s`,
|
||||||
|
repeatCount: String(repeat),
|
||||||
|
keyPoints: keyPoints[direction] || '0;1',
|
||||||
|
keyTimes: '0;1',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedSvgEdgeComponent(props: EdgeProps) {
|
||||||
|
const data = (props.data || {}) as AnimatedEdgeData
|
||||||
|
const connectionType = data.connectionType || 'ethernet'
|
||||||
|
const color = CONNECTION_COLORS[connectionType] || DEFAULT_COLOR
|
||||||
|
const pathType = data.path ?? 'smoothstep'
|
||||||
|
const shape = data.shape ?? 'circle'
|
||||||
|
|
||||||
|
const [edgePath] = getPath(props, pathType)
|
||||||
|
const motionProps = getAnimateMotionProps(data)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseEdge
|
||||||
|
path={edgePath}
|
||||||
|
style={{
|
||||||
|
stroke: color,
|
||||||
|
strokeWidth: props.selected ? 3 : 2,
|
||||||
|
...(connectionType === 'wifi' || connectionType === 'wan' || connectionType === 'vpn'
|
||||||
|
? { strokeDasharray: connectionType === 'wifi' ? '3,3' : '8,4' }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<circle r={0} fill={color}>
|
||||||
|
<animateMotion
|
||||||
|
path={edgePath}
|
||||||
|
calcMode="linear"
|
||||||
|
{...motionProps}
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="0;3;3;3;0"
|
||||||
|
keyTimes="0;0.05;0.5;0.95;1"
|
||||||
|
dur={motionProps.dur}
|
||||||
|
repeatCount={motionProps.repeatCount}
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
{shape === 'package' && (
|
||||||
|
<rect x={-4} y={-4} width={8} height={8} rx={2} fill={color} opacity={0.8}>
|
||||||
|
<animateMotion
|
||||||
|
path={edgePath}
|
||||||
|
calcMode="linear"
|
||||||
|
{...motionProps}
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnimatedSvgEdge = memo(AnimatedSvgEdgeComponent)
|
||||||
20
frontend/src/components/network/ui/base-handle.tsx
Normal file
20
frontend/src/components/network/ui/base-handle.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { Handle, type HandleProps } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type BaseHandleProps = HandleProps
|
||||||
|
|
||||||
|
export function BaseHandle({ className, children, ...props }: ComponentProps<typeof Handle>) {
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
|
||||||
|
'opacity-0 group-hover:opacity-100',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Handle>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
frontend/src/components/network/ui/base-node.tsx
Normal file
56
frontend/src/components/network/ui/base-node.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'bg-card text-heading relative rounded-lg border border-default',
|
||||||
|
'transition-colors hover:border-hover',
|
||||||
|
'in-[.selected]:border-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeHeader({ className, ...props }: ComponentProps<'header'>) {
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
{...props}
|
||||||
|
className={cn('flex flex-row items-center gap-2 px-3 py-2', className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeHeaderTitle({ className, ...props }: ComponentProps<'h3'>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
data-slot="base-node-title"
|
||||||
|
className={cn('select-none flex-1 text-xs font-semibold text-heading', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeContent({ className, ...props }: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="base-node-content"
|
||||||
|
className={cn('flex flex-col gap-y-1 px-3 pb-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseNodeFooter({ className, ...props }: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="base-node-footer"
|
||||||
|
className={cn('flex flex-col items-center gap-y-1 border-t border-default px-3 pt-1.5 pb-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/src/components/network/ui/labeled-group-node.tsx
Normal file
68
frontend/src/components/network/ui/labeled-group-node.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { ReactNode, ComponentProps } from 'react'
|
||||||
|
import { Panel, NodeResizer, type NodeProps, type PanelPosition } from '@xyflow/react'
|
||||||
|
import { BaseNode } from './base-node'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type GroupNodeLabelProps = ComponentProps<'div'>
|
||||||
|
|
||||||
|
export function GroupNodeLabel({ children, className, ...props }: GroupNodeLabelProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full" {...props}>
|
||||||
|
<div className={cn('bg-card text-muted-foreground w-fit p-2 text-[10px] font-semibold uppercase tracking-wider', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupNodeData {
|
||||||
|
label?: string
|
||||||
|
groupType?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GroupNodeProps = Partial<NodeProps> & {
|
||||||
|
label?: ReactNode
|
||||||
|
position?: PanelPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabelClassName(position?: PanelPosition): string {
|
||||||
|
switch (position) {
|
||||||
|
case 'top-left': return 'rounded-br-sm'
|
||||||
|
case 'top-center': return 'rounded-b-sm'
|
||||||
|
case 'top-right': return 'rounded-bl-sm'
|
||||||
|
case 'bottom-left': return 'rounded-tr-sm'
|
||||||
|
case 'bottom-right': return 'rounded-tl-sm'
|
||||||
|
case 'bottom-center': return 'rounded-t-sm'
|
||||||
|
default: return 'rounded-br-sm'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupNode({ data, selected }: NodeProps) {
|
||||||
|
const nodeData = data as unknown as GroupNodeData
|
||||||
|
const label = nodeData.label || 'Group'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={150}
|
||||||
|
minHeight={100}
|
||||||
|
lineStyle={{ borderColor: 'var(--color-accent)', borderWidth: 1 }}
|
||||||
|
handleStyle={{ width: 8, height: 8, borderColor: 'var(--color-accent)', background: 'var(--color-card)' }}
|
||||||
|
/>
|
||||||
|
<BaseNode
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full min-h-[100px] min-w-[150px] overflow-hidden rounded-lg bg-elevated/30 border-default/50',
|
||||||
|
selected && 'border-accent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Panel className="m-0 p-0" position="top-left">
|
||||||
|
<GroupNodeLabel className={getLabelClassName('top-left')}>
|
||||||
|
{label}
|
||||||
|
</GroupNodeLabel>
|
||||||
|
</Panel>
|
||||||
|
</BaseNode>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/components/network/ui/labeled-handle.tsx
Normal file
39
frontend/src/components/network/ui/labeled-handle.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
import { type HandleProps, Position } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { BaseHandle } from './base-handle'
|
||||||
|
|
||||||
|
const flexDirections: Record<string, string> = {
|
||||||
|
[Position.Top]: 'flex-col',
|
||||||
|
[Position.Right]: 'flex-row-reverse justify-end',
|
||||||
|
[Position.Bottom]: 'flex-col-reverse justify-end',
|
||||||
|
[Position.Left]: 'flex-row',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabeledHandle({
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
handleClassName,
|
||||||
|
title,
|
||||||
|
position,
|
||||||
|
...props
|
||||||
|
}: HandleProps &
|
||||||
|
ComponentProps<'div'> & {
|
||||||
|
title: string
|
||||||
|
handleClassName?: string
|
||||||
|
labelClassName?: string
|
||||||
|
}) {
|
||||||
|
const { ref, ...handleProps } = props
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={title}
|
||||||
|
className={cn('relative flex items-center', flexDirections[position], className)}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<BaseHandle position={position} className={handleClassName} {...handleProps} />
|
||||||
|
<label className={cn('text-muted-foreground text-[10px] font-mono px-1.5', labelClassName)}>
|
||||||
|
{title}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/components/network/ui/node-status-indicator.tsx
Normal file
43
frontend/src/components/network/ui/node-status-indicator.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
|
||||||
|
|
||||||
|
const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
|
||||||
|
online: 'border-emerald-400',
|
||||||
|
offline: 'border-red-400',
|
||||||
|
degraded: 'border-yellow-400',
|
||||||
|
unknown: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_GLOW: Record<NodeStatus, string> = {
|
||||||
|
online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]',
|
||||||
|
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
|
||||||
|
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
|
||||||
|
unknown: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeStatusIndicatorProps {
|
||||||
|
status?: NodeStatus
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeStatusIndicator({ status = 'unknown', children, className }: NodeStatusIndicatorProps) {
|
||||||
|
if (status === 'unknown') {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border-2 transition-colors',
|
||||||
|
STATUS_BORDER_COLORS[status],
|
||||||
|
STATUS_GLOW[status],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/network/ui/node-tooltip.tsx
Normal file
77
frontend/src/components/network/ui/node-tooltip.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode, type ComponentProps } from 'react'
|
||||||
|
import { NodeToolbar, type NodeToolbarProps } from '@xyflow/react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface NodeTooltipContextValue {
|
||||||
|
visible: boolean
|
||||||
|
show: () => void
|
||||||
|
hide: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeTooltipContext = createContext<NodeTooltipContextValue>({
|
||||||
|
visible: false,
|
||||||
|
show: () => {},
|
||||||
|
hide: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const show = useCallback(() => setVisible(true), [])
|
||||||
|
const hide = useCallback(() => setVisible(false), [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
</NodeTooltipContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeTooltipTrigger({
|
||||||
|
children,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
...props
|
||||||
|
}: ComponentProps<'div'>) {
|
||||||
|
const { show, hide } = useContext(NodeTooltipContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
show()
|
||||||
|
onMouseEnter?.(e)
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
hide()
|
||||||
|
onMouseLeave?.(e)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeTooltipContent({
|
||||||
|
className,
|
||||||
|
position,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: Omit<NodeToolbarProps, 'children'> & { children: ReactNode }) {
|
||||||
|
const { visible } = useContext(NodeTooltipContext)
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeToolbar
|
||||||
|
position={position}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-default bg-elevated px-3 py-2',
|
||||||
|
'pointer-events-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NodeToolbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
660
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
Normal file
660
frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
ReactFlowProvider,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
addEdge,
|
||||||
|
useReactFlow,
|
||||||
|
getNodesBounds,
|
||||||
|
getViewportForBounds,
|
||||||
|
type Node,
|
||||||
|
type Edge,
|
||||||
|
type Connection,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
|
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
||||||
|
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
||||||
|
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
||||||
|
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 { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
||||||
|
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||||
|
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
||||||
|
|
||||||
|
type ContextMenuState = {
|
||||||
|
type: 'node' | 'canvas'
|
||||||
|
position: { x: number; y: number }
|
||||||
|
nodeId?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
function DiagramEditorInner() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { getNodes, fitView, screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([])
|
||||||
|
|
||||||
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) ?? null : null
|
||||||
|
const selectedEdge = selectedEdgeId ? edges.find(e => e.id === selectedEdgeId) ?? null : null
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const [deviceTypes, setDeviceTypes] = useState<DeviceTypeResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(!!id)
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||||
|
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
||||||
|
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
||||||
|
|
||||||
|
const {
|
||||||
|
copyNodes,
|
||||||
|
pasteNodes,
|
||||||
|
duplicateNodes,
|
||||||
|
selectAll,
|
||||||
|
deleteSelected,
|
||||||
|
hasClipboard,
|
||||||
|
} = useCanvasShortcuts({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
setIsDirty: (v: boolean) => setIsDirty(v),
|
||||||
|
canvasRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
||||||
|
onNodesChange(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])
|
||||||
|
|
||||||
|
const loadDeviceTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const types = await deviceTypesApi.list()
|
||||||
|
setDeviceTypes(types)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadDeviceTypes() }, [loadDeviceTypes])
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
if (n.nodeType === 'group') {
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: 'group',
|
||||||
|
position: n.position,
|
||||||
|
style: n.style || { width: 300, height: 200 },
|
||||||
|
data: {
|
||||||
|
label: n.label,
|
||||||
|
groupType: n.type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: 'device',
|
||||||
|
position: n.position,
|
||||||
|
style: n.style || { width: 120, height: 120 },
|
||||||
|
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,
|
||||||
|
routing: e.routing ?? null,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
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])
|
||||||
|
|
||||||
|
const serializeNodes = useCallback((): DiagramNode[] => {
|
||||||
|
return getNodes().map(n => {
|
||||||
|
if (n.type === 'group') {
|
||||||
|
const data = n.data as Record<string, unknown>
|
||||||
|
const width = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 300)
|
||||||
|
const height = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 200)
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: (data.groupType as string) || 'subnet',
|
||||||
|
label: (data.label as string) || 'Group',
|
||||||
|
position: n.position,
|
||||||
|
properties: {} as DeviceProperties,
|
||||||
|
nodeType: 'group',
|
||||||
|
style: { width, height },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = n.data as unknown as DeviceNodeData
|
||||||
|
const dw = typeof n.style?.width === 'number' ? n.style.width : (n.measured?.width ?? 120)
|
||||||
|
const dh = typeof n.style?.height === 'number' ? n.style.height : (n.measured?.height ?? 120)
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
type: data.deviceType,
|
||||||
|
label: data.label,
|
||||||
|
position: n.position,
|
||||||
|
properties: data.properties,
|
||||||
|
style: { width: dw, height: dh },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [getNodes])
|
||||||
|
|
||||||
|
const serializeEdges = useCallback((): DiagramEdge[] => {
|
||||||
|
return edges.map(e => {
|
||||||
|
const d = (e.data as Record<string, unknown>) || {}
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
label: (e.label as string) || null,
|
||||||
|
connectionType: d.connectionType as string || 'ethernet',
|
||||||
|
speed: d.speed as string || null,
|
||||||
|
notes: d.notes as string || null,
|
||||||
|
routing: d.routing as string || null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [edges])
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (isDirtyRef.current && diagramIdRef.current) {
|
||||||
|
handleSave()
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [handleSave])
|
||||||
|
|
||||||
|
const onConnect = useCallback((connection: Connection) => {
|
||||||
|
setEdges(eds => addEdge({
|
||||||
|
...connection,
|
||||||
|
type: 'connection',
|
||||||
|
data: { connectionType: 'ethernet' },
|
||||||
|
}, eds))
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [setEdges])
|
||||||
|
|
||||||
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
setIsDragOver(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDragLeave = useCallback((event: React.DragEvent) => {
|
||||||
|
const relatedTarget = event.relatedTarget as HTMLElement | null
|
||||||
|
if (relatedTarget && (event.currentTarget as HTMLElement).contains(relatedTarget)) return
|
||||||
|
setIsDragOver(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const currentNodes = getNodes()
|
||||||
|
const isInSelection = currentNodes.find(n => n.id === node.id)?.selected
|
||||||
|
if (!isInSelection) {
|
||||||
|
setNodes(nds => nds.map(n => ({ ...n, selected: n.id === node.id })))
|
||||||
|
setSelectedNodeId(node.id)
|
||||||
|
setSelectedEdgeId(null)
|
||||||
|
}
|
||||||
|
setContextMenu({
|
||||||
|
type: 'node',
|
||||||
|
position: { x: event.clientX, y: event.clientY },
|
||||||
|
nodeId: node.id,
|
||||||
|
})
|
||||||
|
}, [getNodes, setNodes, setSelectedNodeId, setSelectedEdgeId])
|
||||||
|
|
||||||
|
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setContextMenu({
|
||||||
|
type: 'canvas',
|
||||||
|
position: { x: event.clientX, y: event.clientY },
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setContextMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onDrop = useCallback((event: React.DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setIsDragOver(false)
|
||||||
|
|
||||||
|
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY })
|
||||||
|
|
||||||
|
// Handle device drops
|
||||||
|
const deviceRaw = event.dataTransfer.getData('application/reactflow-device')
|
||||||
|
if (deviceRaw) {
|
||||||
|
const { slug, label, category } = JSON.parse(deviceRaw) as { slug: string; label: string; category: string }
|
||||||
|
const newNode: Node = {
|
||||||
|
id: `device-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
type: 'device',
|
||||||
|
position,
|
||||||
|
style: { width: 120, height: 120 },
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle group drops
|
||||||
|
const groupRaw = event.dataTransfer.getData('application/reactflow-group')
|
||||||
|
if (groupRaw) {
|
||||||
|
const { slug, label } = JSON.parse(groupRaw) as { slug: string; label: string }
|
||||||
|
const newNode: Node = {
|
||||||
|
id: `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||||
|
type: 'group',
|
||||||
|
position,
|
||||||
|
style: { width: 300, height: 200 },
|
||||||
|
data: {
|
||||||
|
label,
|
||||||
|
groupType: slug,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
setNodes(nds => [...nds, newNode])
|
||||||
|
setIsDirty(true)
|
||||||
|
}
|
||||||
|
}, [setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
|
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 } : {}),
|
||||||
|
...(updates.routing !== undefined ? { routing: updates.routing } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [setEdges])
|
||||||
|
|
||||||
|
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
||||||
|
setEdges(eds => eds.map(e => {
|
||||||
|
if (e.id !== edgeId) return e
|
||||||
|
return { ...e, type: edgeType }
|
||||||
|
}))
|
||||||
|
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))
|
||||||
|
setSelectedNodeId(null)
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [setNodes, setEdges])
|
||||||
|
|
||||||
|
const handleDeleteEdge = useCallback((edgeId: string) => {
|
||||||
|
setEdges(eds => eds.filter(e => e.id !== edgeId))
|
||||||
|
setSelectedEdgeId(null)
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [setEdges])
|
||||||
|
|
||||||
|
const handleBringToFront = useCallback((nodeId: string) => {
|
||||||
|
setNodes(nds => {
|
||||||
|
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
|
||||||
|
return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [setNodes])
|
||||||
|
|
||||||
|
const handleSendToBack = useCallback((nodeId: string) => {
|
||||||
|
setNodes(nds => {
|
||||||
|
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
|
||||||
|
return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n)
|
||||||
|
})
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [setNodes])
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
const handleExportPng = useCallback(async () => {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
toast.warning('Add some devices to the diagram before exporting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { toPng } = await import('html-to-image')
|
||||||
|
const IMAGE_WIDTH = 1920
|
||||||
|
const IMAGE_HEIGHT = 1080
|
||||||
|
const bounds = getNodesBounds(nodes)
|
||||||
|
const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15)
|
||||||
|
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
|
||||||
|
if (!flowEl) {
|
||||||
|
toast.error('Could not find canvas to export')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dataUrl = await toPng(flowEl, {
|
||||||
|
backgroundColor: '#16181f',
|
||||||
|
width: IMAGE_WIDTH,
|
||||||
|
height: IMAGE_HEIGHT,
|
||||||
|
style: {
|
||||||
|
width: `${IMAGE_WIDTH}px`,
|
||||||
|
height: `${IMAGE_HEIGHT}px`,
|
||||||
|
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.png`
|
||||||
|
a.href = dataUrl
|
||||||
|
a.click()
|
||||||
|
} catch {
|
||||||
|
toast.error('PNG export failed — try Print > Save as PDF instead')
|
||||||
|
}
|
||||||
|
}, [nodes, name])
|
||||||
|
|
||||||
|
const handleExportPdf = useCallback(() => {
|
||||||
|
window.print()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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}
|
||||||
|
isDirty={isDirty}
|
||||||
|
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="relative flex-1 min-h-0" ref={canvasRef}>
|
||||||
|
<NetworkCanvas
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={handleNodesChange}
|
||||||
|
onEdgesChange={handleEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
onNodeSelect={setSelectedNodeId}
|
||||||
|
onEdgeSelect={setSelectedEdgeId}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragLeave={onDragLeave}
|
||||||
|
isDragOver={isDragOver}
|
||||||
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
|
onPaneClick={closeContextMenu}
|
||||||
|
/>
|
||||||
|
{nodes.length === 0 && !loading && (
|
||||||
|
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{nodes.length > 0 && (
|
||||||
|
<AIAssistPanel
|
||||||
|
onGenerate={handleAIGenerate}
|
||||||
|
getExistingBounds={getExistingBounds}
|
||||||
|
hasNodes={nodes.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<PropertiesPanel
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
selectedEdge={selectedEdge}
|
||||||
|
onNodeUpdate={handleNodeUpdate}
|
||||||
|
onEdgeUpdate={handleEdgeUpdate}
|
||||||
|
onEdgeTypeChange={handleEdgeTypeChange}
|
||||||
|
onBringToFront={handleBringToFront}
|
||||||
|
onSendToBack={handleSendToBack}
|
||||||
|
onDeleteNode={handleDeleteNode}
|
||||||
|
onDeleteEdge={handleDeleteEdge}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{contextMenu && (
|
||||||
|
<ContextMenu
|
||||||
|
position={contextMenu.position}
|
||||||
|
actions={
|
||||||
|
contextMenu.type === 'node'
|
||||||
|
? getNodeMenuActions({
|
||||||
|
onCopy: copyNodes,
|
||||||
|
onDuplicate: duplicateNodes,
|
||||||
|
onBringToFront: () => { if (contextMenu.nodeId) handleBringToFront(contextMenu.nodeId) },
|
||||||
|
onSendToBack: () => { if (contextMenu.nodeId) handleSendToBack(contextMenu.nodeId) },
|
||||||
|
onDelete: () => {
|
||||||
|
const nodeId = contextMenu.nodeId
|
||||||
|
setContextMenu(null)
|
||||||
|
if (nodeId) setPendingDeleteNodeId(nodeId)
|
||||||
|
else deleteSelected()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: getCanvasMenuActions({
|
||||||
|
onPaste: pasteNodes,
|
||||||
|
onSelectAll: selectAll,
|
||||||
|
onFitView: () => fitView({ padding: 0.2 }),
|
||||||
|
hasClipboard: hasClipboard(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onClose={closeContextMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pendingDeleteNodeId && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-50 flex justify-center">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-3 rounded-lg border border-default bg-card px-4 py-2.5 shadow-lg">
|
||||||
|
<span className="text-xs text-muted-foreground">Delete this device?</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingDeleteNodeId(null)}
|
||||||
|
className="rounded border border-default px-3 py-1 text-xs text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { handleDeleteNode(pendingDeleteNodeId); setPendingDeleteNodeId(null) }}
|
||||||
|
className="rounded bg-red-500/20 px-3 py-1 text-xs font-medium text-red-400 hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiagramEditor() {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<DiagramEditorInner />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
323
frontend/src/pages/NetworkDiagrams/index.tsx
Normal file
323
frontend/src/pages/NetworkDiagrams/index.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { networkDiagramsApi } from '@/api'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { CATEGORY_COLORS } from '@/components/network/nodes/deviceRegistry'
|
||||||
|
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
|
||||||
|
|
||||||
|
const OTHER_COLOR = '#4f5666'
|
||||||
|
|
||||||
|
function TopologyBar({ categoryCounts, nodeCount }: { categoryCounts: Record<string, number>; nodeCount: number }) {
|
||||||
|
if (nodeCount === 0) return null
|
||||||
|
const sorted = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)
|
||||||
|
const tooltipLabel = sorted.map(([cat, count]) => `${count} ${cat}`).join(' · ')
|
||||||
|
return (
|
||||||
|
<div className="group/bar relative flex h-2 w-full overflow-hidden rounded-full" title={tooltipLabel}>
|
||||||
|
{sorted.map(([cat, count]) => (
|
||||||
|
<div
|
||||||
|
key={cat}
|
||||||
|
style={{
|
||||||
|
width: `${(count / nodeCount) * 100}%`,
|
||||||
|
backgroundColor: CATEGORY_COLORS[cat] ?? OTHER_COLOR,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||||||
|
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!clientDropdownOpen) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (clientDropdownRef.current && !clientDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setClientDropdownOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [clientDropdownOpen])
|
||||||
|
|
||||||
|
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)
|
||||||
|
setConfirmArchiveId(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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div className="relative w-48" ref={clientDropdownRef}>
|
||||||
|
<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>
|
||||||
|
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
</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 && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
{d.node_count > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{menuOpenId === d.id && (
|
||||||
|
<div className="absolute right-2 top-10 z-50 w-44 rounded border border-default bg-card py-1 shadow-lg">
|
||||||
|
{confirmArchiveId === d.id ? (
|
||||||
|
<>
|
||||||
|
<p className="px-3 py-1.5 text-[10px] text-muted-foreground">Archive this diagram?</p>
|
||||||
|
<div className="flex gap-1 px-2 pb-1.5">
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirmArchiveId(null) }}
|
||||||
|
className="flex-1 rounded border border-default px-2 py-1 text-[10px] text-primary hover:bg-elevated"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); handleArchive(d.id) }}
|
||||||
|
className="flex-1 rounded bg-red-500/20 px-2 py-1 text-[10px] font-medium text-red-400 hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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(); setConfirmArchiveId(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ const DevBranchingPage = lazyWithRetry(() => import('@/pages/DevBranchingPage'))
|
|||||||
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
|
const GuidesHubPage = lazyWithRetry(() => import('@/pages/GuidesHubPage'))
|
||||||
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
|
const GuideDetailPage = lazyWithRetry(() => import('@/pages/GuideDetailPage'))
|
||||||
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage'))
|
const AccountSettingsPage = lazyWithRetry(() => import('@/pages/AccountSettingsPage'))
|
||||||
|
const NetworkDiagramsPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams'))
|
||||||
|
const DiagramEditorPage = lazyWithRetry(() => import('@/pages/NetworkDiagrams/DiagramEditor'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
const AdminLayout = lazyWithRetry(() => import('@/components/admin/AdminLayout'))
|
||||||
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
|
const AdminDashboardPage = lazyWithRetry(() => import('@/pages/admin/DashboardPage'))
|
||||||
@@ -195,6 +197,9 @@ export const router = sentryCreateBrowserRouter([
|
|||||||
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
{ path: 'scripts', element: page(ScriptLibraryPage) },
|
||||||
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
{ path: 'scripts/manage', element: page(ScriptManagePage) },
|
||||||
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
{ path: 'script-builder', element: page(ScriptBuilderPage) },
|
||||||
|
{ path: 'network-diagrams', element: page(NetworkDiagramsPage) },
|
||||||
|
{ path: 'network-diagrams/new', element: page(DiagramEditorPage) },
|
||||||
|
{ path: 'network-diagrams/:id', element: page(DiagramEditorPage) },
|
||||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||||
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
|
{ path: 'assistant/:sessionId', element: page(AssistantChatPage) },
|
||||||
|
|||||||
@@ -98,3 +98,4 @@ export * from './script-builder'
|
|||||||
export * from './integrations'
|
export * from './integrations'
|
||||||
export * from './notification'
|
export * from './notification'
|
||||||
export type * from './public-templates'
|
export type * from './public-templates'
|
||||||
|
export * from './network-diagram'
|
||||||
|
|||||||
134
frontend/src/types/network-diagram.ts
Normal file
134
frontend/src/types/network-diagram.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
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
|
||||||
|
nodeType?: string
|
||||||
|
style?: { width?: number; height?: number } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramEdge {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
label: string | null
|
||||||
|
connectionType: string
|
||||||
|
speed: string | null
|
||||||
|
notes: string | null
|
||||||
|
routing?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTypeResponse {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
label: string
|
||||||
|
category: string
|
||||||
|
is_system: boolean
|
||||||
|
account_id: string
|
||||||
|
sort_order: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTypeCreate {
|
||||||
|
slug: string
|
||||||
|
label: string
|
||||||
|
category: string
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkDiagramResponse {
|
||||||
|
id: string
|
||||||
|
account_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
|
||||||
|
category_counts: Record<string, 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user