feat: network diagrams UX overhaul — icons, empty canvas, properties panel
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,20 @@ from app.schemas.network_diagram import (
|
|||||||
)
|
)
|
||||||
from app.services import network_diagram_ai_service
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
|
router = APIRouter(prefix="/network-diagrams", tags=["network-diagrams"])
|
||||||
@@ -48,14 +62,26 @@ def _diagram_to_response(diagram: NetworkDiagram) -> NetworkDiagramResponse:
|
|||||||
return NetworkDiagramResponse.model_validate(diagram)
|
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 []
|
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(
|
return NetworkDiagramListItem(
|
||||||
id=diagram.id,
|
id=diagram.id,
|
||||||
name=diagram.name,
|
name=diagram.name,
|
||||||
client_name=diagram.client_name,
|
client_name=diagram.client_name,
|
||||||
description=diagram.description,
|
description=diagram.description,
|
||||||
node_count=len(nodes),
|
node_count=len(nodes),
|
||||||
|
category_counts=category_counts,
|
||||||
created_by=diagram.created_by,
|
created_by=diagram.created_by,
|
||||||
created_at=diagram.created_at,
|
created_at=diagram.created_at,
|
||||||
updated_at=diagram.updated_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)
|
result = await db.execute(stmt)
|
||||||
rows = result.scalars().all()
|
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)
|
@router.post("/", response_model=NetworkDiagramResponse, status_code=201)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class NetworkDiagramListItem(BaseModel):
|
|||||||
client_name: str | None = None
|
client_name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
node_count: int = 0
|
node_count: int = 0
|
||||||
|
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
created_by: UUID | None = None
|
created_by: UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
109
frontend/src/components/network/CanvasEmptyPrompt.tsx
Normal file
109
frontend/src/components/network/CanvasEmptyPrompt.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
|
||||||
|
<div className="pointer-events-auto w-full max-w-lg rounded-xl border border-default bg-card p-8 shadow-2xl">
|
||||||
|
<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 drag devices from the left panel to build manually.
|
||||||
|
</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/50">
|
||||||
|
⌘↵ 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>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex 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 w-full 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import { nodeTypes } from './nodes/nodeTypes'
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
|
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
||||||
|
import type { DeviceNodeData } from './nodes/DeviceNode'
|
||||||
|
|
||||||
interface NetworkCanvasProps {
|
interface NetworkCanvasProps {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
@@ -66,6 +68,12 @@ export function NetworkCanvas({
|
|||||||
onPaneClickProp?.()
|
onPaneClickProp?.()
|
||||||
}, [onNodeSelect, onEdgeSelect, 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 (
|
return (
|
||||||
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
|
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -93,7 +101,7 @@ export function NetworkCanvas({
|
|||||||
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
<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" />
|
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
||||||
<MiniMap
|
<MiniMap
|
||||||
nodeColor="var(--color-bg-elevated)"
|
nodeColor={getNodeColor}
|
||||||
maskColor="rgba(0,0,0,0.5)"
|
maskColor="rgba(0,0,0,0.5)"
|
||||||
className="!border-default !bg-card"
|
className="!border-default !bg-card"
|
||||||
position="bottom-right"
|
position="bottom-right"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Network, Layers, Shield, Wifi, Server, Monitor, Box, Cloud,
|
Router, Layers, ShieldAlert, Wifi, Server, Monitor, Boxes, Package, Cloud,
|
||||||
Printer, Smartphone, HardDrive, Scale, Database, CloudCog,
|
Printer, Smartphone, HardDrive, Gauge, Database, CloudCog,
|
||||||
Cpu, Tablet, Laptop, BatteryCharging, LayoutGrid, RectangleVertical,
|
Cpu, Tablet, Laptop, BatteryCharging, RectangleVertical,
|
||||||
Cable, Camera, KeyRound, Globe,
|
Cable, Camera, KeyRound, Globe, Video, PlugZap, Radio,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export interface DeviceRenderConfig {
|
export interface DeviceRenderConfig {
|
||||||
@@ -11,49 +11,78 @@ export interface DeviceRenderConfig {
|
|||||||
color: string
|
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 = '#f97316'
|
||||||
|
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> = {
|
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||||
'router': { icon: Network, color: 'var(--color-accent)' },
|
// Network layer
|
||||||
'switch': { icon: Layers, color: 'var(--color-text-muted-foreground)' },
|
'router': { icon: Router, color: NETWORK_COLOR },
|
||||||
'firewall': { icon: Shield, color: 'var(--color-accent)' },
|
'switch': { icon: Layers, color: NETWORK_COLOR },
|
||||||
'access-point': { icon: Wifi, color: 'var(--color-text-muted-foreground)' },
|
'access-point': { icon: Wifi, color: NETWORK_COLOR },
|
||||||
'load-balancer': { icon: Scale, color: 'var(--color-text-muted-foreground)' },
|
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
|
||||||
'server': { icon: Server, color: 'var(--color-text-muted-foreground)' },
|
|
||||||
'workstation': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
|
// Security
|
||||||
'vm': { icon: Box, color: 'var(--color-text-muted-foreground)' },
|
'firewall': { icon: ShieldAlert, color: SECURITY_COLOR },
|
||||||
'container': { icon: Cpu, color: 'var(--color-text-muted-foreground)' },
|
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
|
||||||
'nas': { icon: Database, color: 'var(--color-text-muted-foreground)' },
|
|
||||||
'san': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
|
// Compute
|
||||||
'cloud-storage': { icon: CloudCog, color: 'var(--color-text-muted-foreground)' },
|
'server': { icon: Server, color: COMPUTE_COLOR },
|
||||||
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
'vm': { icon: Boxes, color: COMPUTE_COLOR },
|
||||||
'aws': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
'container': { icon: Package, color: COMPUTE_COLOR },
|
||||||
'azure': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
|
||||||
'gcp': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
// Storage
|
||||||
'isp': { icon: Globe, color: 'var(--color-accent)' },
|
'nas': { icon: Database, color: STORAGE_COLOR },
|
||||||
'printer': { icon: Printer, color: 'var(--color-text-muted-foreground)' },
|
'san': { icon: HardDrive, color: STORAGE_COLOR },
|
||||||
'phone': { icon: Smartphone, color: 'var(--color-text-muted-foreground)' },
|
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
|
||||||
'iot': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
|
|
||||||
'camera': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
|
// Cloud / Internet
|
||||||
'tablet': { icon: Tablet, color: 'var(--color-text-muted-foreground)' },
|
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
'laptop': { icon: Laptop, color: 'var(--color-text-muted-foreground)' },
|
'aws': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
'ups': { icon: BatteryCharging, color: 'var(--color-text-muted-foreground)' },
|
'azure': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
'pdu': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
|
'gcp': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
'rack': { icon: RectangleVertical, color: 'var(--color-text-muted-foreground)' },
|
'isp': { icon: Globe, color: CLOUD_COLOR },
|
||||||
'patch-panel': { icon: Cable, color: 'var(--color-text-muted-foreground)' },
|
|
||||||
'nvr': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
|
// Endpoints
|
||||||
'badge-reader': { icon: KeyRound, color: 'var(--color-text-muted-foreground)' },
|
'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> = {
|
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
||||||
'network': { icon: Network, color: 'var(--color-text-muted-foreground)' },
|
'network': { icon: Router, color: NETWORK_COLOR },
|
||||||
'compute': { icon: Server, color: 'var(--color-text-muted-foreground)' },
|
'compute': { icon: Server, color: COMPUTE_COLOR },
|
||||||
'storage': { icon: Database, color: 'var(--color-text-muted-foreground)' },
|
'storage': { icon: Database, color: STORAGE_COLOR },
|
||||||
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
|
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
||||||
'endpoint': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
|
'endpoint': { icon: Monitor, color: ENDPOINT_COLOR },
|
||||||
'infrastructure': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
|
'infrastructure': { icon: PlugZap, color: INFRA_COLOR },
|
||||||
'security': { icon: Shield, color: 'var(--color-text-muted-foreground)' },
|
'security': { icon: ShieldAlert, color: SECURITY_COLOR },
|
||||||
}
|
}
|
||||||
|
|
||||||
const FALLBACK: DeviceRenderConfig = { icon: Box, color: 'var(--color-text-muted-foreground)' }
|
const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR }
|
||||||
|
|
||||||
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
|
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
|
||||||
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
|
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
|
||||||
@@ -62,13 +91,23 @@ export function getDeviceRenderConfig(slug: string, category?: string): DeviceRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CATEGORY_LABELS: Record<string, string> = {
|
export const CATEGORY_LABELS: Record<string, string> = {
|
||||||
'network': 'Network',
|
'network': 'Network',
|
||||||
'compute': 'Compute',
|
'compute': 'Compute',
|
||||||
'storage': 'Storage',
|
'storage': 'Storage',
|
||||||
'cloud': 'Cloud',
|
'cloud': 'Cloud',
|
||||||
'endpoint': 'Endpoints',
|
'endpoint': 'Endpoints',
|
||||||
'infrastructure': 'Infrastructure',
|
'infrastructure': 'Infrastructure',
|
||||||
'security': 'Security',
|
'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']
|
export const CATEGORY_ORDER = ['network', 'compute', 'storage', 'cloud', 'endpoint', 'infrastructure', 'security']
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ interface PropertiesPanelProps {
|
|||||||
onDeleteEdge: (edgeId: string) => void
|
onDeleteEdge: (edgeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['unknown', 'online', 'offline', 'degraded'] as const
|
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
|
const CONNECTION_TYPE_OPTIONS = ['ethernet', 'fiber', 'wifi', 'vpn', 'vlan', 'wan'] as const
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: React.ReactNode }) {
|
function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||||
@@ -46,6 +55,17 @@ function FieldInput({ value, onChange, placeholder, 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({
|
export function PropertiesPanel({
|
||||||
selectedNode,
|
selectedNode,
|
||||||
selectedEdge,
|
selectedEdge,
|
||||||
@@ -74,6 +94,9 @@ export function PropertiesPanel({
|
|||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
Select a device or connection to edit its properties
|
Select a device or connection to edit its properties
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
|
||||||
|
Hover a device to preview its info
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -98,7 +121,7 @@ export function PropertiesPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<FieldLabel>Connection Type</FieldLabel>
|
<FieldLabel>Type</FieldLabel>
|
||||||
<select
|
<select
|
||||||
value={isCustomType ? '__custom__' : connectionType}
|
value={isCustomType ? '__custom__' : connectionType}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -112,7 +135,7 @@ export function PropertiesPanel({
|
|||||||
{CONNECTION_TYPE_OPTIONS.map(opt => (
|
{CONNECTION_TYPE_OPTIONS.map(opt => (
|
||||||
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
||||||
))}
|
))}
|
||||||
<option value="__custom__">Custom...</option>
|
<option value="__custom__">Custom…</option>
|
||||||
</select>
|
</select>
|
||||||
{isCustomType && (
|
{isCustomType && (
|
||||||
<FieldInput
|
<FieldInput
|
||||||
@@ -135,7 +158,7 @@ export function PropertiesPanel({
|
|||||||
<FieldInput
|
<FieldInput
|
||||||
value={(edgeData.notes as string) || ''}
|
value={(edgeData.notes as string) || ''}
|
||||||
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
|
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
|
||||||
placeholder="Port info, cable type..."
|
placeholder="Port info, cable type…"
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,68 +227,104 @@ export function PropertiesPanel({
|
|||||||
|
|
||||||
const nodeData = selectedNode!.data as unknown as DeviceNodeData
|
const nodeData = selectedNode!.data as unknown as DeviceNodeData
|
||||||
const props = nodeData.properties || {} as DeviceProperties
|
const props = nodeData.properties || {} as DeviceProperties
|
||||||
|
const currentStatus = (props.status || 'unknown') as NodeStatus
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-[260px] flex-col border-l border-default bg-sidebar">
|
<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">
|
<div className="border-b border-default px-3 py-2">
|
||||||
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
|
<h3 className="text-xs font-semibold text-heading">Device Properties</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
||||||
|
|
||||||
|
{/* Identity */}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<FieldLabel>Label</FieldLabel>
|
<FieldLabel>Name</FieldLabel>
|
||||||
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>Hostname</FieldLabel>
|
{/* Status badge grid */}
|
||||||
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
|
<div className="flex flex-col gap-1.5">
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>IP Address</FieldLabel>
|
|
||||||
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>Subnet</FieldLabel>
|
|
||||||
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>Vendor</FieldLabel>
|
|
||||||
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>Model</FieldLabel>
|
|
||||||
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>Role</FieldLabel>
|
|
||||||
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>VLAN</FieldLabel>
|
|
||||||
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<FieldLabel>Status</FieldLabel>
|
<FieldLabel>Status</FieldLabel>
|
||||||
<select
|
<div className="grid grid-cols-2 gap-1">
|
||||||
value={props.status || 'unknown'}
|
{STATUS_OPTIONS.map(opt => {
|
||||||
onChange={e => handlePropertyChange('status', e.target.value)}
|
const { color, label } = STATUS_CONFIG[opt]
|
||||||
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
|
const active = currentStatus === opt
|
||||||
>
|
return (
|
||||||
{STATUS_OPTIONS.map(opt => (
|
<button
|
||||||
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
|
key={opt}
|
||||||
))}
|
onClick={() => handlePropertyChange('status', opt)}
|
||||||
</select>
|
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>
|
</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">
|
<div className="flex flex-col gap-1">
|
||||||
<FieldLabel>Notes</FieldLabel>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={props.notes || ''}
|
value={props.notes || ''}
|
||||||
onChange={e => handlePropertyChange('notes', e.target.value)}
|
onChange={e => handlePropertyChange('notes', e.target.value)}
|
||||||
placeholder="Additional notes..."
|
placeholder="Additional notes…"
|
||||||
rows={3}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-default p-3">
|
<div className="border-t border-default p-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => onDeleteNode(selectedNode!.id)}
|
onClick={() => onDeleteNode(selectedNode!.id)}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
useEdgesState,
|
useEdgesState,
|
||||||
addEdge,
|
addEdge,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
|
getNodesBounds,
|
||||||
|
getViewportForBounds,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Connection,
|
type Connection,
|
||||||
@@ -19,6 +21,7 @@ import { DiagramHeader } from '@/components/network/DiagramHeader'
|
|||||||
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
||||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||||
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||||
|
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
||||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||||
@@ -453,9 +456,41 @@ function DiagramEditorInner() {
|
|||||||
return { minX, maxX, minY, maxY }
|
return { minX, maxX, minY, maxY }
|
||||||
}, [getNodes])
|
}, [getNodes])
|
||||||
|
|
||||||
const handleExportPng = useCallback(() => {
|
const handleExportPng = useCallback(async () => {
|
||||||
toast.info('PNG export — use your browser\'s screenshot tool or Print > Save as Image for now')
|
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(() => {
|
const handleExportPdf = useCallback(() => {
|
||||||
window.print()
|
window.print()
|
||||||
@@ -502,7 +537,7 @@ function DiagramEditorInner() {
|
|||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
||||||
<div className="flex flex-1 flex-col min-h-0">
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
<div className="flex-1 min-h-0" ref={canvasRef}>
|
<div className="relative flex-1 min-h-0" ref={canvasRef}>
|
||||||
<NetworkCanvas
|
<NetworkCanvas
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -519,12 +554,17 @@ function DiagramEditorInner() {
|
|||||||
onPaneContextMenu={handlePaneContextMenu}
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
onPaneClick={closeContextMenu}
|
onPaneClick={closeContextMenu}
|
||||||
/>
|
/>
|
||||||
|
{nodes.length === 0 && !loading && (
|
||||||
|
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<AIAssistPanel
|
{nodes.length > 0 && (
|
||||||
onGenerate={handleAIGenerate}
|
<AIAssistPanel
|
||||||
getExistingBounds={getExistingBounds}
|
onGenerate={handleAIGenerate}
|
||||||
hasNodes={nodes.length > 0}
|
getExistingBounds={getExistingBounds}
|
||||||
/>
|
hasNodes={nodes.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PropertiesPanel
|
<PropertiesPanel
|
||||||
selectedNode={selectedNode}
|
selectedNode={selectedNode}
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Search, Network, MoreHorizontal, Upload } from 'lucide-react'
|
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { networkDiagramsApi } from '@/api'
|
import { networkDiagramsApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { CATEGORY_COLORS } from '@/components/network/nodes/deviceRegistry'
|
||||||
import type { NetworkDiagramListItem, DiagramImportData } from '@/types'
|
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)
|
||||||
|
return (
|
||||||
|
<div className="flex h-1 w-full overflow-hidden rounded-full">
|
||||||
|
{sorted.map(([cat, count]) => (
|
||||||
|
<div
|
||||||
|
key={cat}
|
||||||
|
title={`${cat}: ${count}`}
|
||||||
|
style={{
|
||||||
|
width: `${(count / nodeCount) * 100}%`,
|
||||||
|
backgroundColor: CATEGORY_COLORS[cat] ?? OTHER_COLOR,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function NetworkDiagramsPage() {
|
export default function NetworkDiagramsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
|
const [diagrams, setDiagrams] = useState<NetworkDiagramListItem[]>([])
|
||||||
@@ -16,6 +38,18 @@ export default function NetworkDiagramsPage() {
|
|||||||
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
|
const [clientDropdownOpen, setClientDropdownOpen] = useState(false)
|
||||||
const [clientSearch, setClientSearch] = useState('')
|
const [clientSearch, setClientSearch] = useState('')
|
||||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
const [menuOpenId, setMenuOpenId] = 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 () => {
|
const loadDiagrams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -133,7 +167,7 @@ export default function NetworkDiagramsPage() {
|
|||||||
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"
|
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>
|
||||||
<div className="relative w-48">
|
<div className="relative w-48" ref={clientDropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setClientDropdownOpen(prev => !prev)}
|
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"
|
className="flex w-full items-center justify-between rounded border border-default bg-input px-3 py-2 text-sm text-primary"
|
||||||
@@ -141,7 +175,7 @@ export default function NetworkDiagramsPage() {
|
|||||||
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
|
<span className={clientFilter ? 'text-primary' : 'text-muted-foreground'}>
|
||||||
{clientFilter || 'All clients'}
|
{clientFilter || 'All clients'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground">▾</span>
|
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
{clientDropdownOpen && (
|
{clientDropdownOpen && (
|
||||||
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
|
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded border border-default bg-card shadow-lg">
|
||||||
@@ -224,6 +258,11 @@ export default function NetworkDiagramsPage() {
|
|||||||
{d.description && (
|
{d.description && (
|
||||||
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
<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">
|
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||||
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
|
<span>{d.node_count} device{d.node_count !== 1 ? 's' : ''}</span>
|
||||||
<span>{formatDate(d.created_at)}</span>
|
<span>{formatDate(d.created_at)}</span>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface NetworkDiagramListItem {
|
|||||||
client_name: string | null
|
client_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
node_count: number
|
node_count: number
|
||||||
|
category_counts: Record<string, number>
|
||||||
created_by: string | null
|
created_by: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
Reference in New Issue
Block a user