From 6f12e42ebea94d98576d636f8a112194c21860d6 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 5 Apr 2026 00:55:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20network=20diagrams=20UX=20overhaul=20?= =?UTF-8?q?=E2=80=94=20icons,=20empty=20canvas,=20properties=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Colorize: semantic category colors for all device types (network=blue, security=orange, compute=emerald, endpoint=amber, storage=violet, cloud=cyan, infra=steel); better icons (Router, ShieldAlert, Boxes, Package, Gauge, PlugZap, Video, Radio); MiniMap uses category colors - Onboard: centered AI generate prompt on empty canvas with 5 MSP-specific example chips, ⌘↵ shortcut, spinner; AIAssistPanel only shown with nodes - Arrange: properties panel — status badge grid at top, fields grouped into Network (IP/Subnet/VLAN) and Hardware (Hostname/Vendor/Model/Role) sections - Delight: segmented topology color bar on listing cards; backend returns category_counts via single extra query on list endpoint - Harden: real PNG export via html-to-image + getNodesBounds/getViewportForBounds - Polish: ChevronDown replaces unicode ▾, click-outside for client filter, consistent spinner in empty prompt Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/network_diagrams.py | 38 ++++- backend/app/schemas/network_diagram.py | 1 + frontend/package-lock.json | 7 + frontend/package.json | 1 + .../components/network/CanvasEmptyPrompt.tsx | 109 +++++++++++++ .../src/components/network/NetworkCanvas.tsx | 10 +- .../network/nodes/deviceRegistry.ts | 133 ++++++++++------ .../network/panels/PropertiesPanel.tsx | 149 ++++++++++++------ .../pages/NetworkDiagrams/DiagramEditor.tsx | 58 +++++-- frontend/src/pages/NetworkDiagrams/index.tsx | 47 +++++- frontend/src/types/network-diagram.ts | 1 + 11 files changed, 446 insertions(+), 108 deletions(-) create mode 100644 frontend/src/components/network/CanvasEmptyPrompt.tsx diff --git a/backend/app/api/endpoints/network_diagrams.py b/backend/app/api/endpoints/network_diagrams.py index ff5ebfcd..c6841545 100644 --- a/backend/app/api/endpoints/network_diagrams.py +++ b/backend/app/api/endpoints/network_diagrams.py @@ -28,6 +28,20 @@ from app.schemas.network_diagram import ( ) 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"]) @@ -48,14 +62,26 @@ def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse: return NetworkDiagramResponse.model_validate(diagram) -def _diagram_to_list_item(diagram: NetworkDiagram) -> NetworkDiagramListItem: +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, @@ -119,9 +145,17 @@ async def list_diagrams( ) ) + # 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.team_id == current_user.team_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) for r in rows] + return [_diagram_to_list_item(r, custom_slug_category) for r in rows] @router.post("/", response_model=NetworkDiagramResponse, status_code=201) diff --git a/backend/app/schemas/network_diagram.py b/backend/app/schemas/network_diagram.py index da69c3fc..24b98264 100644 --- a/backend/app/schemas/network_diagram.py +++ b/backend/app/schemas/network_diagram.py @@ -83,6 +83,7 @@ class NetworkDiagramListItem(BaseModel): 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cd02c6f0..cb2eb520 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", "immer": "^11.1.3", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", @@ -5331,6 +5332,12 @@ "dev": true, "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index d93a3831..f03d09a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", "immer": "^11.1.3", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", diff --git a/frontend/src/components/network/CanvasEmptyPrompt.tsx b/frontend/src/components/network/CanvasEmptyPrompt.tsx new file mode 100644 index 00000000..ad23f6cf --- /dev/null +++ b/frontend/src/components/network/CanvasEmptyPrompt.tsx @@ -0,0 +1,109 @@ +import { useState, useCallback } from 'react' +import { Sparkles, ArrowRight } 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 [description, setDescription] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + 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]) + + return ( +
+
+
+
+ +

+ Describe your network +

+
+

+ AI will generate the topology in seconds — or drag devices from the left panel to build manually. +

+
+ +
+