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:
chihlasm
2026-04-05 00:55:03 +00:00
parent c0d8163e98
commit 6f12e42ebe
11 changed files with 446 additions and 108 deletions

View 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>
)
}

View File

@@ -13,6 +13,8 @@ import {
} 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[]
@@ -66,6 +68,12 @@ export function NetworkCanvas({
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
@@ -93,7 +101,7 @@ export function NetworkCanvas({
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
<MiniMap
nodeColor="var(--color-bg-elevated)"
nodeColor={getNodeColor}
maskColor="rgba(0,0,0,0.5)"
className="!border-default !bg-card"
position="bottom-right"

View File

@@ -1,9 +1,9 @@
import type { LucideIcon } from 'lucide-react'
import {
Network, Layers, Shield, Wifi, Server, Monitor, Box, Cloud,
Printer, Smartphone, HardDrive, Scale, Database, CloudCog,
Cpu, Tablet, Laptop, BatteryCharging, LayoutGrid, RectangleVertical,
Cable, Camera, KeyRound, Globe,
Router, Layers, ShieldAlert, 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 {
@@ -11,49 +11,78 @@ export interface DeviceRenderConfig {
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> = {
'router': { icon: Network, color: 'var(--color-accent)' },
'switch': { icon: Layers, color: 'var(--color-text-muted-foreground)' },
'firewall': { icon: Shield, color: 'var(--color-accent)' },
'access-point': { icon: Wifi, color: 'var(--color-text-muted-foreground)' },
'load-balancer': { icon: Scale, color: 'var(--color-text-muted-foreground)' },
'server': { icon: Server, color: 'var(--color-text-muted-foreground)' },
'workstation': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
'vm': { icon: Box, color: 'var(--color-text-muted-foreground)' },
'container': { icon: Cpu, color: 'var(--color-text-muted-foreground)' },
'nas': { icon: Database, color: 'var(--color-text-muted-foreground)' },
'san': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
'cloud-storage': { icon: CloudCog, color: 'var(--color-text-muted-foreground)' },
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'aws': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'azure': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'gcp': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'isp': { icon: Globe, color: 'var(--color-accent)' },
'printer': { icon: Printer, color: 'var(--color-text-muted-foreground)' },
'phone': { icon: Smartphone, color: 'var(--color-text-muted-foreground)' },
'iot': { icon: HardDrive, color: 'var(--color-text-muted-foreground)' },
'camera': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
'tablet': { icon: Tablet, color: 'var(--color-text-muted-foreground)' },
'laptop': { icon: Laptop, color: 'var(--color-text-muted-foreground)' },
'ups': { icon: BatteryCharging, color: 'var(--color-text-muted-foreground)' },
'pdu': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
'rack': { icon: RectangleVertical, color: 'var(--color-text-muted-foreground)' },
'patch-panel': { icon: Cable, color: 'var(--color-text-muted-foreground)' },
'nvr': { icon: Camera, color: 'var(--color-text-muted-foreground)' },
'badge-reader': { icon: KeyRound, color: 'var(--color-text-muted-foreground)' },
// Network layer
'router': { icon: Router, color: NETWORK_COLOR },
'switch': { icon: Layers, color: NETWORK_COLOR },
'access-point': { icon: Wifi, color: NETWORK_COLOR },
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
// Security
'firewall': { icon: ShieldAlert, 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: Network, color: 'var(--color-text-muted-foreground)' },
'compute': { icon: Server, color: 'var(--color-text-muted-foreground)' },
'storage': { icon: Database, color: 'var(--color-text-muted-foreground)' },
'cloud': { icon: Cloud, color: 'var(--color-text-muted-foreground)' },
'endpoint': { icon: Monitor, color: 'var(--color-text-muted-foreground)' },
'infrastructure': { icon: LayoutGrid, color: 'var(--color-text-muted-foreground)' },
'security': { icon: Shield, color: 'var(--color-text-muted-foreground)' },
'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: 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 {
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> = {
'network': 'Network',
'compute': 'Compute',
'storage': 'Storage',
'cloud': 'Cloud',
'endpoint': 'Endpoints',
'network': 'Network',
'compute': 'Compute',
'storage': 'Storage',
'cloud': 'Cloud',
'endpoint': 'Endpoints',
'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']

View File

@@ -15,7 +15,16 @@ interface PropertiesPanelProps {
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
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({
selectedNode,
selectedEdge,
@@ -74,6 +94,9 @@ export function PropertiesPanel({
<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>
)
}
@@ -98,7 +121,7 @@ export function PropertiesPanel({
/>
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Connection Type</FieldLabel>
<FieldLabel>Type</FieldLabel>
<select
value={isCustomType ? '__custom__' : connectionType}
onChange={e => {
@@ -112,7 +135,7 @@ export function PropertiesPanel({
{CONNECTION_TYPE_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
<option value="__custom__">Custom...</option>
<option value="__custom__">Custom</option>
</select>
{isCustomType && (
<FieldInput
@@ -135,7 +158,7 @@ export function PropertiesPanel({
<FieldInput
value={(edgeData.notes as string) || ''}
onChange={val => onEdgeUpdate(selectedEdge.id, { notes: val || null })}
placeholder="Port info, cable type..."
placeholder="Port info, cable type"
mono
/>
</div>
@@ -204,68 +227,104 @@ export function PropertiesPanel({
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>Label</FieldLabel>
<FieldLabel>Name</FieldLabel>
<FieldInput value={nodeData.label} onChange={handleLabelChange} placeholder="Device name" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Hostname</FieldLabel>
<FieldInput value={props.hostname || ''} onChange={v => handlePropertyChange('hostname', v)} placeholder="e.g. core-rtr-01" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>IP Address</FieldLabel>
<FieldInput value={props.ip || ''} onChange={v => handlePropertyChange('ip', v)} placeholder="e.g. 10.0.0.1" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Subnet</FieldLabel>
<FieldInput value={props.subnet || ''} onChange={v => handlePropertyChange('subnet', v)} placeholder="e.g. 10.0.0.0/24" mono />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Vendor</FieldLabel>
<FieldInput value={props.vendor || ''} onChange={v => handlePropertyChange('vendor', v)} placeholder="e.g. Cisco" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Model</FieldLabel>
<FieldInput value={props.model || ''} onChange={v => handlePropertyChange('model', v)} placeholder="e.g. ISR 4331" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Role</FieldLabel>
<FieldInput value={props.role || ''} onChange={v => handlePropertyChange('role', v)} placeholder="e.g. Core gateway" />
</div>
<div className="flex flex-col gap-1">
<FieldLabel>VLAN</FieldLabel>
<FieldInput value={props.vlan || ''} onChange={v => handlePropertyChange('vlan', v)} placeholder="e.g. 10" />
</div>
<div className="flex flex-col gap-1">
{/* Status badge grid */}
<div className="flex flex-col gap-1.5">
<FieldLabel>Status</FieldLabel>
<select
value={props.status || 'unknown'}
onChange={e => handlePropertyChange('status', e.target.value)}
className="w-full rounded border border-default bg-input px-2 py-1.5 text-xs text-primary focus:border-accent focus:outline-none"
>
{STATUS_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
</select>
<div 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">
<FieldLabel>Notes</FieldLabel>
<textarea
value={props.notes || ''}
onChange={e => handlePropertyChange('notes', e.target.value)}
placeholder="Additional notes..."
placeholder="Additional notes"
rows={3}
className="w-full resize-none rounded border border-default bg-input px-2 py-1.5 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none"
/>
</div>
</div>
<div className="border-t border-default p-3">
<button
onClick={() => onDeleteNode(selectedNode!.id)}