feat: extract network map builder from PR 124 (#137)

* feat: add device_types table with system seed data

Creates DeviceType SQLAlchemy model and migration 073 that provisions the
device_types table with 28 system-seeded device types across 7 categories
(network, compute, storage, cloud, endpoint, infrastructure, security).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network_diagrams table

Create NetworkDiagram SQLAlchemy model with JSONB nodes/edges, team-scoped with client/asset metadata, and Alembic migration 074.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Pydantic schemas for device types and network diagrams

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add device types CRUD router

Adds GET/POST/PUT/DELETE endpoints at /device-types with team-scoped access. System types are read-only; custom types are scoped to the creating team.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add AI generation service for network diagrams

Adds network_diagram_ai_service.py with generate_diagram() function that
calls the AI provider to convert plain-English network descriptions into
structured DiagramNode/DiagramEdge data. Registers the action in
ACTION_MODEL_MAP as a standard-tier route.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add network diagrams CRUD + AI generate + export/import router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TypeScript types for network diagrams

Adds all interfaces for network diagrams and device types including
DiagramNode, DiagramEdge, DeviceProperties, NetworkDiagramResponse,
AI generate request/response, import/export shapes, and list item types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add frontend API clients for device types and network diagrams

Adds deviceTypesApi (list, create, update, remove) and networkDiagramsApi
(list, get, create, update, archive, duplicate, exportJson, importJson,
aiGenerate, listClients) following the existing apiClient module pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add device registry, DeviceNode, ConnectionEdge for React Flow

Creates the React Flow building blocks for the network diagram editor:
device type registry with icon/color mappings, DeviceNode component with
status indicators and connection handles, ConnectionEdge with per-type
styling, and nodeTypes/edgeTypes registration maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add DeviceToolbar panel with search, categories, drag-drop, custom type creation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add PropertiesPanel for node and edge property editing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add AIAssistPanel with replace and merge modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add NetworkCanvas wrapper and DiagramHeader components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add DiagramEditor page assembling all panels with auto-save and AI generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Network Diagrams list page with search, client filter, import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add Network Maps to sidebar navigation and router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve TypeScript errors in DeviceToolbar and DiagramEditor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve stale selection bug in network diagram PropertiesPanel

Selection state now stores IDs and derives objects from live arrays,
so edits in PropertiesPanel inputs reflect immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add React Flow UI foundation components for network diagrams

BaseNode (structured node shell with header/content/footer slots),
BaseHandle (styled connection handle), LabeledHandle (handle with
port label), NodeStatusIndicator (status border effect),
NodeTooltip (hover details via NodeToolbar).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add LabeledGroupNode and AnimatedSvgEdge components

GroupNode for subnet/VLAN/site grouping with positioned label badge.
AnimatedSvgEdge for traffic flow visualization with animated SVG
shape along edge path. Both registered in type maps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: DeviceNode uses BaseNode, BaseHandle, StatusIndicator, Tooltip

Replaces hand-rolled node layout with composable React Flow UI
components. Status is now a border effect instead of a dot.
Hover tooltip shows hostname, IP, vendor, role, notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add grouping toolbar items and traffic flow toggle

DeviceToolbar gets Subnet/VLAN/Site/DMZ grouping section with
drag-drop. PropertiesPanel gets Show Traffic toggle that switches
edges between connection and animated types. DiagramEditor handles
both device and group node drops.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address code review findings for React Flow UI integration

- Use screenToFlowPosition() for drop coordinates (fixes zoom/pan bug)
- Remove duplicate selection border from DeviceNode (BaseNode handles it)
- Add w-full to GroupNode for proper container sizing
- Remove unused 'selected' destructuring from DeviceNode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ISP icon to network diagram device registry

Globe icon with accent color, under cloud category.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve drag-and-drop feel in network diagram editor

Grip icons on draggable toolbar items, press effect on drag start,
dashed border overlay with 'Drop to add' text when dragging over canvas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ContextMenu component for network diagram editor

Charcoal-styled context menu with action factories for node
and canvas variants. Viewport-clamped positioning, auto-dismiss
on click outside, escape, or scroll.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add useCanvasShortcuts hook for copy/paste/duplicate

Keyboard shortcuts with preventDefault and input guard.
Clipboard stores nodes with relative positions and edge indices.
Paste computes canvas center via screenToFlowPosition.
Duplicate offsets +30px. Supports both device and group nodes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: wire context menu and keyboard shortcuts into diagram editor

Right-click context menus for nodes (copy/duplicate/delete) and
canvas (paste/select-all/fit-view). Right-click selects the node
per spec. serializeNodes now handles group nodes correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: context menu dismisses on pane click, ISP in toolbar

Context menu now closes when clicking anywhere on the canvas via
onPaneClick prop. ISP device added as built-in toolbar item under
Internet section so it's always available without a database entry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: backend code review fixes for network diagrams

- Replace legacy Optional imports with modern str | None syntax
- Type JSONB columns as Mapped[list[dict[str, Any]]]
- Escape SQL LIKE wildcards (%, _) in diagram search
- Type DiagramNode.position as Position(x, y) Pydantic model
- Wrap AI response parsing in KeyError handler for clean 422 errors
- Remove unused Optional/TYPE_CHECKING imports from schemas/models
- Extract _get_available_slugs helper to DRY duplicate queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: network diagram editor UX — straight edges, snap-to-grid, ISP in Cloud, group resize

- Straight edges: replace SmoothStepEdge with BaseEdge + getStraightPath so
  connections draw direct diagonal lines instead of orthogonal bent paths
- Snap-to-grid: add snapToGrid/snapGrid=[20,20] to NetworkCanvas so nodes
  align consistently when dragged
- ISP in Cloud: remove standalone "Internet" sidebar section, inject ISP into
  the Cloud category loop with search support and correct item count
- Group node resize: add NodeResizer to GroupNode (subnet/VLAN/site/DMZ),
  handles visible when selected; dimensions saved/restored correctly on
  reload (also fixes group node load bug where type was always 'device')
- DiagramNode type: add nodeType and style optional fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: network diagram team_id guard + multi-style edge routing

Backend:
- Guard create_diagram with 422 if current_user.team_id is None (prevents
  NOT NULL constraint crash for accounts not yet assigned to a team)
- Add routing field to DiagramEdge schema (straight/curved/step)

Frontend:
- ConnectionEdge now supports straight (default), curved (bezier), and
  step (smooth-step) routing per-edge via routing field in edge data
- PropertiesPanel Connection section gets a Line Style toggle:
  Straight | Curved | Step buttons, active state highlights in accent
- handleEdgeUpdate and serializeEdges now propagate the routing field
- DiagramEdge type gets optional routing field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* chore: drop changelog noise from network extraction

* fix: align network map builder with account isolation

* feat: add manual create option for network maps

* feat: make manual network map creation easier to discover

* fix(network-maps): address design critique — harden, normalize, clarify, polish

- Archive: two-step inline confirm in card dropdown menu
- Delete Device/Edge: two-step inline confirm in PropertiesPanel footer
- Context menu Delete: floating confirm bar instead of immediate deletion
- AI Generate New: two-step confirm when replacing existing diagram nodes
- DiagramHeader: show 'Unsaved changes' in amber when isDirty and not saving
- deviceRegistry: SECURITY_COLOR #f97316 → #f87171 (deprecated ember orange removed)
- CanvasEmptyPrompt: remove backdrop-blur (design system violation)
- CanvasEmptyPrompt: remove redundant 'Skip AI' bottom button (duplicate of Build manually card)
- CanvasEmptyPrompt: rounded-xl/rounded-2xl → rounded-lg, border-2 → border
- Topology bar: h-1 → h-2 + native tooltip with category breakdown
- AIAssistPanel: replace pulse-dot loading with spinner (consistent with rest of feature)
- ContextMenu: add shadow-lg (consistent with other dropdowns)
- DeviceNode tooltip: Position.Bottom → Position.Top (avoids canvas-edge clipping)
- CanvasEmptyPrompt: raise ⌘↵ hint from /50 opacity to full text-muted-foreground

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(network-maps): bring to front / send to back layering for nodes

Three entry points for z-index control:
- Right-click context menu: Bring to Front / Send to Back with ] / [ shortcuts, separated by dividers from copy/delete groups
- Properties panel: Layer row with Bring Front + Send Back buttons, tooltip shows keyboard shortcut
- Keyboard: ] brings selected node(s) to front, [ sends to back (skips when input focused)

Context menu also gains divider support (dividerBefore flag) for visual grouping.
Layering handlers use max/min zIndex across all nodes so repeated presses always stack correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: swap switch icon from Layers → Network (Lucide)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: icon size picker (S/M/L) on device nodes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: drag-to-resize device nodes + BrickWallFire for firewall

- NodeResizer on DeviceNode (same pattern as group nodes); icon scales
  proportionally with node width, clamped 16–60px
- Removes S/M/L static picker — resize is now direct manipulation
- firewall: ShieldAlert → BrickWallFire

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: trigger Railway rebuild

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add missing hero_001.jpg to git (was untracked, broke Railway deploy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: ShieldAlert still referenced in CATEGORY_DEFAULTS after icon swap

Removed ShieldAlert from imports when swapping firewall icon to BrickWallFire
but left it in CATEGORY_DEFAULTS — runtime crash, device toolbar empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(network): proportional node resize with locked aspect ratio

Nodes grew into rectangles because NodeResizer had no aspect ratio
constraint, minWidth != minHeight, and icon/text only scaled from width.

- DeviceNode: add keepAspectRatio + equal minWidth/minHeight (80×80),
  maxWidth/maxHeight (280×280), scale icon and label/IP font sizes from
  Math.min(width, height) so all content grows uniformly
- DiagramEditor: set explicit 120×120 style on dropped device nodes so
  React Flow has a definite starting size for aspect ratio calculation
- DiagramEditor: persist device node style (width/height) in
  serializeNodes and restore it on load so size survives save/reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(lint): suppress ESLint errors in network diagram components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #137.
This commit is contained in:
chihlasm
2026-04-13 02:38:01 -04:00
committed by GitHub
parent af5ceea7f9
commit abd79bc763
45 changed files with 4826 additions and 3 deletions

View File

@@ -5,7 +5,7 @@ import {
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
ListChecks, Download, BarChart3,
Settings, Pin, PinOff,
History, FileText,
History, FileText, Network,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -86,10 +86,11 @@ export function Sidebar() {
{
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
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: [
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || 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: '/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: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },

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

View 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 },
]
}

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

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

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

View File

@@ -0,0 +1,7 @@
import { ConnectionEdge } from './ConnectionEdge'
import { AnimatedSvgEdge } from '../ui/animated-svg-edge'
export const edgeTypes = {
connection: ConnectionEdge,
animated: AnimatedSvgEdge,
}

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

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

View 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']

View File

@@ -0,0 +1,7 @@
import { DeviceNode } from './DeviceNode'
import { GroupNode } from '../ui/labeled-group-node'
export const nodeTypes = {
device: DeviceNode,
group: GroupNode,
}

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

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

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

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

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

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

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

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

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

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