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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user