feat: network diagrams — draw.io-style editor #139

Merged
chihlasm merged 31 commits from feat/network-diagrams into main 2026-04-14 05:44:27 +00:00
28 changed files with 4032 additions and 137 deletions

View File

@@ -1,10 +1,12 @@
"""Network diagrams API endpoints."""
import base64
import logging
from datetime import datetime, timezone
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -27,7 +29,7 @@ from app.schemas.network_diagram import (
DiagramNode,
DiagramEdge,
)
from app.services import network_diagram_ai_service
from app.services import network_diagram_ai_service, storage_service
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
_SLUG_CATEGORY: dict[str, str] = {
@@ -83,6 +85,7 @@ def _diagram_to_list_item(
description=diagram.description,
node_count=len(nodes),
category_counts=category_counts,
thumbnail_url=diagram.thumbnail_url,
created_by=diagram.created_by,
created_at=diagram.created_at,
updated_at=diagram.updated_at,
@@ -305,6 +308,34 @@ async def import_diagram(
)
class ThumbnailUploadRequest(BaseModel):
data_url: str # base64 PNG data URL: "data:image/png;base64,..."
@router.post("/{diagram_id}/thumbnail", status_code=204)
async def upload_thumbnail(
diagram_id: UUID,
body: ThumbnailUploadRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db)
try:
header, encoded = body.data_url.split(",", 1)
except ValueError:
raise HTTPException(status_code=422, detail="Invalid data URL format")
image_bytes = base64.b64decode(encoded)
storage_key = await storage_service.upload_file(
file_data=image_bytes,
filename=f"thumbnail-{diagram_id}.png",
content_type="image/png",
account_id=str(current_user.account_id),
)
presigned_url = storage_service.get_presigned_url(storage_key)
diagram.thumbnail_url = presigned_url
await db.commit()
@router.post("/ai-generate", response_model=AIGenerateResponse)
async def ai_generate_diagram(
data: AIGenerateRequest,

View File

@@ -22,12 +22,20 @@ class DeviceProperties(BaseModel):
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
class NodeStyle(BaseModel):
width: float | None = None
height: float | None = None
class DiagramNode(BaseModel):
id: str
type: str
label: str
position: Position
properties: DeviceProperties = Field(default_factory=DeviceProperties)
nodeType: str | None = None
style: NodeStyle | None = None
parentId: str | None = None
class DiagramEdge(BaseModel):
@@ -84,6 +92,7 @@ class NetworkDiagramListItem(BaseModel):
description: str | None = None
node_count: int = 0
category_counts: dict[str, int] = Field(default_factory=dict)
thumbnail_url: str | None = None
created_by: UUID | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,757 @@
# Network Diagram Editor — Draw.io-Style Implementation Document
> **Date:** 2026-04-13
> **Status:** Proposed
> **Audience:** Product, frontend, backend, and agentic workers
> **Goal:** Build a production-grade network diagram editor inside ResolutionFlow that feels close to draw.io while staying MSP- and topology-focused.
---
## Executive Summary
ResolutionFlow should implement network diagrams as a first-class editor surface, not as a lightweight canvas utility. The right target is not "clone draw.io exactly," but "deliver draw.io-grade editing quality for MSP network topology work."
The recommended path is:
1. **Use the existing network-diagram branch architecture as the foundation**
2. **Ship the already-proven CRUD/editor shell as Phase 1**
3. **Invest the next phases in interaction quality, editor commands, and interoperability**
4. **Keep a ResolutionFlow-native JSON schema as the source of truth**
5. **Add draw.io compatibility at import/export boundaries, not at the storage layer**
This preserves delivery speed while giving the product room to grow into a robust diagramming tool.
---
## Product Goal
Build a network diagram creation tool that:
- Feels familiar to users who already know draw.io
- Supports MSP workflows better than a generic diagramming app
- Makes manual editing fast and safe
- Supports AI-assisted generation and clean-up without depending on AI for correctness
- Fits ResolutionFlow's existing frontend/backend architecture cleanly
Success is not measured by raw feature count alone. Success means a user can open the editor and confidently:
- Create a clean network map from scratch
- Drag devices from a stencil palette onto a canvas
- Connect, label, group, align, copy, duplicate, and organize elements quickly
- Save and revisit diagrams safely
- Export the result for documentation and client communication
---
## Existing Repo Context
ResolutionFlow already has strong signals for this direction:
- The main architecture is React 19 + Vite + TypeScript on the frontend, FastAPI + PostgreSQL on the backend.
- There are existing design and plan docs for network diagrams:
- `docs/superpowers/specs/2026-04-04-react-flow-ui-network-diagrams-design.md`
- `docs/superpowers/specs/2026-04-04-network-diagram-ux-improvements-design.md`
- `docs/superpowers/plans/2026-04-04-react-flow-ui-network-diagrams.md`
- `docs/superpowers/plans/2026-04-04-network-diagram-ux-improvements.md`
- Git history shows a substantial prior implementation on `feat/network-map-builder-prod`.
That branch already included:
- Backend model, schema, migration, API routes, and AI generation service
- Frontend list page and editor page
- React Flow-based canvas
- Device registry and custom node types
- Context menu, copy/paste/duplicate shortcuts, and drag/drop improvements
- Inspector/properties panel
- Import/export JSON
This is important: **the best implementation path is to revive and harden that architecture, not to invent a parallel one.**
---
## Non-Goals
The first implementation should not try to become a full whiteboard platform.
Out of scope for the initial milestone:
- Real-time multiplayer collaboration
- Comments/presence/cursors
- Arbitrary slide decks or presentation features
- BPMN/UML/general enterprise diagram libraries
- Full draw.io parity on day one
- Replacing ResolutionFlow's tree editor architecture
The editor should stay focused on network topology and MSP documentation use cases.
---
## User Experience Target
The editor should feel close to draw.io in the following ways:
- Fast drag/drop from a left stencil panel
- Predictable selection behavior
- Context menus and keyboard shortcuts
- Snap-to-grid and alignment affordances
- Resizable groups and containers
- Good edge routing options
- Easy text and label editing
- Familiar import/export workflows
The editor should exceed draw.io in MSP-specific workflows:
- Device types that reflect real client environments
- AI-generated starting diagrams from text descriptions
- Client and asset metadata
- Future hooks into PSA, assets, tickets, and documentation
---
## Recommended Architecture
### Frontend
Use a dedicated feature area:
- `frontend/src/pages/NetworkDiagrams/`
- `frontend/src/components/network/`
- `frontend/src/api/networkDiagrams.ts`
- `frontend/src/types/network-diagram.ts`
Use React Flow as the canvas/rendering engine.
Why React Flow:
- Already used conceptually in the codebase
- Strong node/edge rendering model
- Good selection/dragging/viewport primitives
- Enough flexibility for custom nodes, groups, and edge styles
- Faster path to production than building custom canvas behavior from scratch
### Backend
Use a document-style data model stored in PostgreSQL JSONB:
- `network_diagrams` table
- JSONB `nodes`
- JSONB `edges`
- Metadata columns for account scoping, names, timestamps, archive state
Why document storage:
- Flexible schema evolution
- Fast implementation
- Simple import/export
- Easy autosave
- Works well with editor-state persistence
### Source of Truth
Use a ResolutionFlow-native schema as the system of record.
Do not store draw.io XML as the primary database format.
Instead:
- Store native JSON internally
- Import from draw.io into native JSON
- Export native JSON to draw.io-compatible XML when needed
This keeps the application decoupled from an external vendor format.
---
## Recommended File Layout
### Frontend
- `frontend/src/pages/NetworkDiagrams/index.tsx`
- Diagram list page
- `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx`
- Editor orchestration layer
- `frontend/src/components/network/NetworkCanvas.tsx`
- React Flow wrapper and viewport surface
- `frontend/src/components/network/DiagramHeader.tsx`
- Save state, title, metadata actions, export/import controls
- `frontend/src/components/network/ContextMenu.tsx`
- Node/canvas context menus
- `frontend/src/components/network/CanvasEmptyPrompt.tsx`
- Empty-state guidance
- `frontend/src/components/network/panels/DeviceToolbar.tsx`
- Stencil palette and searchable device library
- `frontend/src/components/network/panels/PropertiesPanel.tsx`
- Inspector for node/edge editing
- `frontend/src/components/network/panels/AIAssistPanel.tsx`
- AI generate/merge UX
- `frontend/src/components/network/hooks/useCanvasShortcuts.ts`
- Keyboard shortcuts and clipboard behavior
- `frontend/src/components/network/hooks/useDiagramCommands.ts`
- Shared command layer for actions invoked by keyboard, menus, and toolbar
- `frontend/src/components/network/nodes/*`
- Node components, registry, types, and render configuration
- `frontend/src/components/network/edges/*`
- Edge components and routing styles
- `frontend/src/api/networkDiagrams.ts`
- CRUD, import/export, AI generation, duplication
- `frontend/src/types/network-diagram.ts`
- Shared client-side typing
### Backend
- `backend/app/models/network_diagram.py`
- SQLAlchemy model
- `backend/app/schemas/network_diagram.py`
- Pydantic request/response models
- `backend/app/api/endpoints/network_diagrams.py`
- CRUD + import/export + AI routes
- `backend/app/services/network_diagram_ai_service.py`
- AI generation and later AI clean-up/layout assistance
- `backend/alembic/versions/074_add_network_diagrams_table.py`
- Initial schema migration
- `backend/app/models/network_diagram_version.py`
- Later addition for version history
- `backend/app/api/endpoints/network_diagram_versions.py`
- Later addition for restore/history flows
---
## Data Model
### V1 Database Model
The existing JSONB storage pattern is good for a first release:
- `id`
- `account_id`
- `name`
- `client_name`
- `asset_name`
- `description`
- `nodes` JSONB
- `edges` JSONB
- `thumbnail_url`
- `is_archived`
- `created_by`
- `created_at`
- `updated_at`
### Recommended Node Schema
Use a richer internal node shape than a minimal device-only object. The schema should be resilient enough to support future features without painful migration.
Recommended fields:
- `id`
- `kind`
- `device | group | text | shape | image`
- `type`
- device slug or shape subtype
- `label`
- `position`
- `size`
- `rotation`
- `zIndex`
- `parentId`
- `ports`
- `style`
- `data`
Examples:
- `kind=device`, `type=router`
- `kind=group`, `type=subnet`
- `kind=text`, `type=label`
### Recommended Edge Schema
- `id`
- `source`
- `target`
- `sourcePort`
- `targetPort`
- `label`
- `routing`
- `straight | step | orthogonal | curved`
- `waypoints`
- `style`
- `data`
### Why This Matters
The prior branch stored a solid but fairly lean `DiagramNode`/`DiagramEdge`. That is enough to start, but draw.io-like editing will need more than just `position`, `label`, and `connectionType`.
If we adopt the richer shape early, we reduce future rework in:
- manual bend-point support
- z-ordering
- groups/containers
- text/shape nodes
- port-specific connections
---
## Editor State Model
The editor should be built around four distinct layers of state:
### 1. Persisted Diagram State
What is saved to the backend:
- nodes
- edges
- metadata
### 2. Editor UI State
What lives only in the browser while editing:
- selected node IDs
- selected edge IDs
- open context menu
- active tool
- inspector visibility
- drag-over state
- clipboard reference
### 3. Derived View State
- filtered palette items
- current selection bounds
- whether paste is available
- whether align/distribute commands are valid
### 4. History State
- undo stack
- redo stack
- last autosave timestamp
The editor page should orchestrate these, but command logic should not be scattered across component trees.
---
## Command System
To make the tool feel like draw.io, add a shared command layer.
Recommended hook:
- `frontend/src/components/network/hooks/useDiagramCommands.ts`
This hook should expose commands like:
- `copySelection`
- `pasteSelection`
- `duplicateSelection`
- `deleteSelection`
- `selectAll`
- `fitView`
- `bringToFront`
- `sendToBack`
- `alignLeft`
- `alignCenter`
- `alignRight`
- `alignTop`
- `alignMiddle`
- `alignBottom`
- `distributeHorizontally`
- `distributeVertically`
- `groupSelection`
- `ungroupSelection`
- `lockSelection`
- `unlockSelection`
All of the following should call the same command functions:
- keyboard shortcuts
- toolbar buttons
- context menu items
- future command palette entries
This avoids duplicate logic and keeps behavior consistent.
---
## Phase-by-Phase Delivery Plan
## Phase 1 — Foundation MVP
### Objective
Ship a usable network diagram editor quickly using the existing branch shape.
### Scope
- Diagram list page
- Create/edit/archive/duplicate
- React Flow canvas
- Searchable device palette
- Device and group nodes
- Edge creation
- Properties panel
- Save + autosave
- Import/export ResolutionFlow JSON
- Basic AI generation from natural language
- Context menu
- Keyboard shortcuts:
- copy
- paste
- duplicate
- select all
- fit view
- delete
### Frontend Work
- Restore `NetworkDiagrams` page routes and navigation
- Restore `DiagramEditor`
- Restore `NetworkCanvas`
- Restore node/edge registries
- Restore clipboard + context menu behavior
- Add command-layer extraction if time allows
### Backend Work
- Restore migration, model, schemas, endpoints
- Validate account scoping and tenant isolation
- Restore import/export endpoint
- Restore AI generate endpoint
### Acceptance Criteria
- User can create and save a network map
- User can reopen it later
- User can drag devices from palette onto canvas
- User can connect nodes and label links
- User can copy/paste/duplicate/delete
- User can import/export JSON
- User can generate a starter diagram from text
---
## Phase 2 — Draw.io-Grade Editing Quality
### Objective
Close the biggest UX gap between a basic node editor and a real diagramming tool.
### Scope
- Snap-to-guides in addition to snap-to-grid
- Alignment commands
- Distribution commands
- Multi-select improvements
- Better z-order handling
- Inline text editing
- Better group/container behavior
- Rich edge routing choices
- Manual bend points
- Port-aware connection handling
- Keyboard nudging and modifier behavior
### New/Expanded Files
- `hooks/useDiagramCommands.ts`
- `hooks/useSelectionBounds.ts`
- `components/network/guides/*`
- `components/network/edges/*`
### Acceptance Criteria
- Multi-select editing feels reliable
- Align/distribute work predictably
- User can produce a polished topology without fighting the canvas
- Connectors can be shaped intentionally rather than only auto-routed
---
## Phase 3 — Interoperability and Export
### Objective
Let the editor fit real customer and internal documentation workflows.
### Scope
- SVG export
- PNG export
- PDF export
- Thumbnail generation
- Draw.io XML import
- Draw.io XML export
### Implementation Notes
Do not try to mirror every draw.io primitive internally.
Instead:
- Build a compatible subset for network maps
- Translate supported draw.io elements into native nodes/edges/groups/text
- Emit supported native diagrams back into draw.io XML
- Warn on unsupported constructs during import
### Acceptance Criteria
- A diagram can be exported for customer-facing documentation
- A supported draw.io network map can be imported into ResolutionFlow
- Users can move work between tools without losing essential topology content
---
## Phase 4 — ResolutionFlow-Native Differentiation
### Objective
Make this better than a generic diagram editor for MSP use cases.
### Scope
- AI merge into existing topology
- AI tidy-up / auto-layout refinement
- Asset-aware device metadata
- Client templates
- Common MSP topology starter kits
- Diagram-to-ticket or diagram-to-flow linking
- Version history and restore
### Acceptance Criteria
- AI helps users start faster and clean up faster
- Diagrams connect to the rest of the ResolutionFlow product
- Version history reduces fear of experimentation
---
## Draw.io Parity Matrix
| Capability | Priority | Notes |
|-----------|----------|-------|
| Drag/drop stencil palette | P0 | Must feel immediate and stable |
| Node resize/move/select | P0 | Core editor behavior |
| Edge creation and labeling | P0 | Core topology use case |
| Copy/paste/duplicate/delete | P0 | Expected baseline |
| Context menu + keyboard shortcuts | P0 | Must be fast and familiar |
| Snap-to-grid | P0 | Already supported directionally |
| Align/distribute | P1 | Big usability leap |
| Grouping/containers | P1 | Important for subnets, rooms, racks |
| Edge routing modes | P1 | Necessary for professional-looking diagrams |
| Inline text editing | P1 | Draw.io expectation |
| Layers/lock/hide | P2 | Useful once diagrams get large |
| Draw.io import/export | P2 | Important for migration and adoption |
| Realtime collaboration | P3 | Valuable, but not early priority |
---
## Risks and Mitigations
### Risk: Editor complexity balloons too quickly
**Mitigation**
- Keep the MVP narrow
- Use phased delivery
- Center everything around the command layer
### Risk: React Flow abstraction limits parity
**Mitigation**
- Validate manual bend points, grouping, and selection ergonomics early
- If a specific advanced behavior is awkward, implement it in a focused extension layer instead of abandoning React Flow entirely
### Risk: Import/export compatibility becomes a trap
**Mitigation**
- Support a documented subset of draw.io semantics
- Keep native JSON as the canonical internal model
- Warn clearly on unsupported import constructs
### Risk: AI-generated diagrams feel impressive but unreliable
**Mitigation**
- Treat AI output as a starting point only
- Keep editing UX first-class
- Make merge mode explicit and safe
### Risk: Users lose work through autosave/history gaps
**Mitigation**
- Add diagram versioning soon after MVP
- Preserve a local dirty-state guard
- Add explicit "saved at" feedback
---
## Versioning Recommendation
Version history should be planned early, even if shipped after the MVP.
Recommended table:
- `network_diagram_versions`
- `id`
- `diagram_id`
- `account_id`
- `snapshot` JSONB
- `created_by`
- `label`
- `created_at`
Recommended triggers for version creation:
- explicit "Save Version"
- before destructive import-replace
- before AI replace mode
- optionally every N minutes when dirty changes are substantial
This is one of the highest-leverage additions for user trust.
---
## Testing Strategy
### Frontend
- Unit-test command logic
- Unit-test serialization/deserialization
- Component-test context menu and shortcut behavior
- E2E test core editor flows:
- create diagram
- drag node
- connect nodes
- save and reload
- copy/paste
- import/export
### Backend
- API tests for CRUD
- API tests for tenant isolation
- API tests for import/export validation
- API tests for duplicate/archive
- AI endpoint tests with mocked provider output
### Manual QA
Required flows:
- New blank diagram
- Existing diagram edit
- Large diagram performance
- Multi-select behavior
- Keyboard shortcut guard behavior while inputs are focused
- Import malformed JSON
- AI merge into populated canvas
---
## Suggested Delivery Order
### Slice 1
- Restore backend migration/model/schema/router
- Restore types and API client
- Restore list page
### Slice 2
- Restore editor shell and canvas
- Restore nodes, edges, palette, save/load
### Slice 3
- Restore context menu, clipboard, shortcuts, inspector
- Validate dirty-state and autosave behavior
### Slice 4
- Restore AI generation and merge mode
- Tighten import/export UX
### Slice 5
- Implement command layer
- Add align/distribute/z-order polish
### Slice 6
- Add version history
- Add export polish and thumbnails
### Slice 7
- Add draw.io XML import/export subset
---
## Recommended Immediate Next Step
The best immediate implementation move is:
1. **Rebase or selectively port `feat/network-map-builder-prod` into the current codebase**
2. **Use that as the Phase 1 foundation**
3. **Do not start by rewriting the editor architecture**
That approach is faster, lower risk, and already aligned with the repo's documented direction.
---
## Worker Notes
If agentic workers implement this plan, they should:
- Reuse the existing network-diagram branch structure where possible
- Avoid introducing a second diagram architecture
- Keep native JSON as the canonical schema
- Treat command centralization as a priority, not an afterthought
- Ship MVP behavior first, then polish toward draw.io parity in focused slices
---
## Summary
ResolutionFlow can support a draw.io-like network diagram editor without fighting its current stack. The prior network-diagram branch already proves the right foundation:
- React Flow canvas
- FastAPI CRUD
- JSONB persistence
- device registry
- AI assist
- context menus
- keyboard shortcuts
- import/export
The real work now is not deciding whether to build it. The real work is:
- restoring that foundation cleanly,
- formalizing the internal schema,
- adding a reusable command system,
- and iterating on the editor interactions until the experience feels professional.
That path gives ResolutionFlow a practical, high-value network topology tool quickly, while preserving a credible route to near-draw.io quality over the next phases.

File diff suppressed because it is too large Load Diff

View File

@@ -51,6 +51,10 @@ export const networkDiagramsApi = {
return response.data
},
async uploadThumbnail(id: string, dataUrl: string): Promise<void> {
await apiClient.post(`/network-diagrams/${id}/thumbnail`, { data_url: dataUrl })
},
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
return response.data

View File

@@ -1,5 +1,10 @@
import { useEffect, useRef } from 'react'
import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack } from 'lucide-react'
import {
Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Ungroup, Maximize2, BringToFront, SendToBack,
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
} from 'lucide-react'
import { cn } from '@/lib/utils'
interface MenuAction {
@@ -15,9 +20,41 @@ interface ContextMenuProps {
position: { x: number; y: number }
actions: MenuAction[]
onClose: () => void
onAlignLeft?: () => void
onAlignRight?: () => void
onAlignCenterH?: () => void
onAlignTop?: () => void
onAlignBottom?: () => void
onAlignCenterV?: () => void
onDistributeH?: () => void
onDistributeV?: () => void
canAlign?: boolean
canDistribute?: boolean
onGroupSelection?: () => void
onUngroupSelection?: () => void
canGroup?: boolean
canUngroup?: boolean
}
export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
export function ContextMenu({
position,
actions,
onClose,
onAlignLeft,
onAlignRight,
onAlignCenterH,
onAlignTop,
onAlignBottom,
onAlignCenterV,
onDistributeH,
onDistributeV,
canAlign,
canDistribute,
onGroupSelection,
onUngroupSelection,
canGroup,
canUngroup,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null)
const clampedPosition = { ...position }
@@ -83,6 +120,59 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
</button>
</div>
))}
{canAlign && (
<>
<div className="border-t border-default my-1" />
<div className="px-3 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Align</div>
<button onClick={() => { onAlignLeft?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignStartVertical size={13} /> <span>Align Left</span>
</button>
<button onClick={() => { onAlignCenterH?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignCenterHorizontal size={13} /> <span>Align Center</span>
</button>
<button onClick={() => { onAlignRight?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignEndVertical size={13} /> <span>Align Right</span>
</button>
<button onClick={() => { onAlignTop?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignStartHorizontal size={13} /> <span>Align Top</span>
</button>
<button onClick={() => { onAlignCenterV?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignCenterVertical size={13} /> <span>Align Middle</span>
</button>
<button onClick={() => { onAlignBottom?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignEndHorizontal size={13} /> <span>Align Bottom</span>
</button>
{canDistribute && (
<>
<div className="border-t border-default my-1" />
<div className="px-3 py-0.5 text-[10px] font-medium text-muted uppercase tracking-wider">Distribute</div>
<button onClick={() => { onDistributeH?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignHorizontalSpaceAround size={13} /> <span>Space Horizontally</span>
</button>
<button onClick={() => { onDistributeV?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<AlignVerticalSpaceAround size={13} /> <span>Space Vertically</span>
</button>
</>
)}
</>
)}
{(canGroup || canUngroup) && (
<>
<div className="border-t border-default my-1" />
{canGroup && (
<button onClick={() => { onGroupSelection?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<BoxSelect size={13} /> <span>Group Selection</span>
</button>
)}
{canUngroup && (
<button onClick={() => { onUngroupSelection?.(); onClose() }} className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated">
<Ungroup size={13} /> <span>Ungroup</span>
</button>
)}
</>
)}
</div>
)
}

View File

@@ -1,6 +1,9 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react'
import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, FileOutput, Upload, Cable } from 'lucide-react'
import { cn } from '@/lib/utils'
export type InteractionMode = 'select' | 'pan' | 'connect'
interface DiagramHeaderProps {
name: string
@@ -12,8 +15,17 @@ interface DiagramHeaderProps {
onNameChange: (name: string) => void
onSave: () => void
onExportPng: () => void
onExportSvg: () => void
onExportPdf: () => void
onExportJson: () => void
onExportDrawio: () => void
onImportDrawio: () => void // draw.io import — triggered from Export menu
onUndo: () => void
onRedo: () => void
canUndo: boolean
canRedo: boolean
interactionMode: InteractionMode
onModeChange: (mode: InteractionMode) => void
}
export function DiagramHeader({
@@ -26,8 +38,17 @@ export function DiagramHeader({
onNameChange,
onSave,
onExportPng,
onExportSvg,
onExportPdf,
onExportJson,
onExportDrawio,
onImportDrawio,
onUndo,
onRedo,
canUndo,
canRedo,
interactionMode,
onModeChange,
}: DiagramHeaderProps) {
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
@@ -88,6 +109,72 @@ export function DiagramHeader({
<div className="mx-2 h-5 w-px bg-border-default" />
<div className="flex items-center gap-1">
<button
onClick={onUndo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Undo2 size={16} />
</button>
<button
onClick={onRedo}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
className="p-1.5 rounded text-muted-foreground hover:text-primary hover:bg-elevated disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<Redo2 size={16} />
</button>
</div>
<div className="mx-2 h-5 w-px bg-border-default" />
{/* Interaction mode toggle */}
<div className="flex items-center overflow-hidden rounded border border-default">
<button
onClick={() => onModeChange('select')}
title="Select (V)"
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 text-xs transition-colors',
interactionMode === 'select'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<MousePointer2 size={15} />
<span className="hidden sm:inline">Select</span>
</button>
<button
onClick={() => onModeChange('pan')}
title="Pan (H)"
className={cn(
'flex items-center gap-1.5 border-l border-default px-2.5 py-1.5 text-xs transition-colors',
interactionMode === 'pan'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<Hand size={15} />
<span className="hidden sm:inline">Pan</span>
</button>
<button
onClick={() => onModeChange('connect')}
title="Connect (C)"
className={cn(
'flex items-center gap-1.5 border-l border-default px-2.5 py-1.5 text-xs transition-colors',
interactionMode === 'connect'
? 'bg-elevated text-primary'
: 'text-muted-foreground hover:text-primary hover:bg-elevated/50',
)}
>
<Cable size={15} />
<span className="hidden sm:inline">Connect</span>
</button>
</div>
<div className="mx-2 h-5 w-px bg-border-default" />
{editing ? (
<input
ref={inputRef}
@@ -135,30 +222,51 @@ export function DiagramHeader({
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
Export / Import
</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">
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Export as</div>
<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
<Image size={12} /> PNG
</button>
<button
onClick={() => { onExportSvg(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileCode size={12} /> SVG
</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
<FileText size={12} /> 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
<FileJson size={12} /> JSON
</button>
)}
<button
onClick={() => { onExportDrawio(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<FileOutput size={12} /> draw.io
</button>
<div className="my-1 border-t border-default" />
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Import</div>
<button
onClick={() => { onImportDrawio(); setShowExportMenu(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
>
<Upload size={12} /> draw.io file
</button>
</div>
)}
</div>

View File

@@ -0,0 +1,129 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
interface ShortcutRow {
keys: string[]
label: string
}
interface ShortcutGroup {
title: string
rows: ShortcutRow[]
}
const GROUPS: ShortcutGroup[] = [
{
title: 'Modes',
rows: [
{ keys: ['V'], label: 'Select mode' },
{ keys: ['H'], label: 'Pan mode' },
{ keys: ['C'], label: 'Connect mode' },
{ keys: ['Space'], label: 'Temporary pan (hold)' },
],
},
{
title: 'Canvas',
rows: [
{ keys: ['Ctrl', 'Shift', 'F'], label: 'Fit view' },
{ keys: ['Ctrl', 'A'], label: 'Select all' },
{ keys: ['Ctrl', 'Z'], label: 'Undo' },
{ keys: ['Ctrl', 'Y'], label: 'Redo' },
],
},
{
title: 'Nodes',
rows: [
{ keys: ['Ctrl', 'C'], label: 'Copy' },
{ keys: ['Ctrl', 'V'], label: 'Paste' },
{ keys: ['Ctrl', 'D'], label: 'Duplicate' },
{ keys: ['Del'], label: 'Delete selected' },
{ keys: [']'], label: 'Bring to front' },
{ keys: ['['], label: 'Send to back' },
],
},
{
title: 'Nudge',
rows: [
{ keys: ['↑', '↓', '←', '→'], label: 'Move 1px' },
{ keys: ['Shift', '↑↓←→'], label: 'Move 10px' },
],
},
]
interface KeyboardShortcutsOverlayProps {
onClose: () => void
}
function Kbd({ children }: { children: string }) {
return (
<span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded border border-white/10 bg-white/[0.07] px-1.5 text-[10px] font-mono text-muted-foreground">
{children}
</span>
)
}
export function KeyboardShortcutsOverlay({ onClose }: KeyboardShortcutsOverlayProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [onClose])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-[2px]"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-xl rounded-lg border border-default bg-card shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-default px-5 py-3.5">
<div>
<h2 className="font-heading text-sm font-semibold text-heading">Keyboard Shortcuts</h2>
<p className="text-[11px] text-muted-foreground">Press <Kbd>?</Kbd> anytime to open this</p>
</div>
<button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded border border-default text-muted-foreground hover:border-hover hover:text-primary"
>
<X size={13} />
</button>
</div>
{/* Shortcut grid */}
<div className="grid grid-cols-2 gap-0 divide-x divide-default">
{GROUPS.map((group, gi) => (
<div key={group.title} className={gi >= 2 ? 'border-t border-default' : ''}>
<div className="px-5 pb-2 pt-4">
<div className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{group.title}
</div>
<div className="space-y-1.5">
{group.rows.map(row => (
<div key={row.label} className="flex items-center justify-between gap-3">
<span className="text-xs text-primary">{row.label}</span>
<div className="flex shrink-0 items-center gap-0.5">
{row.keys.map((k, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && <span className="text-[10px] text-muted-foreground/50">+</span>}
<Kbd>{k}</Kbd>
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
{/* Footer hint */}
<div className="border-t border-default px-5 py-2.5 text-[11px] text-muted-foreground">
On Mac, <Kbd>Ctrl</Kbd> = <Kbd> Cmd</Kbd>
</div>
</div>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import {
MiniMap,
BackgroundVariant,
type OnConnect,
type OnReconnect,
type OnNodesChange,
type OnEdgesChange,
type Node,
@@ -15,6 +16,8 @@ import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
import type { DeviceNodeData } from './nodes/DeviceNode'
import type { InteractionMode } from './DiagramHeader'
import { cn } from '@/lib/utils'
interface NetworkCanvasProps {
nodes: Node[]
@@ -22,6 +25,7 @@ interface NetworkCanvasProps {
onNodesChange: OnNodesChange
onEdgesChange: OnEdgesChange
onConnect: OnConnect
onReconnect: OnReconnect<Edge>
onNodeSelect: (nodeId: string | null) => void
onEdgeSelect: (edgeId: string | null) => void
onDrop: (event: React.DragEvent) => void
@@ -31,6 +35,7 @@ interface NetworkCanvasProps {
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
onPaneClick?: () => void
interactionMode?: InteractionMode
}
export function NetworkCanvas({
@@ -39,6 +44,7 @@ export function NetworkCanvas({
onNodesChange,
onEdgesChange,
onConnect,
onReconnect,
onNodeSelect,
onEdgeSelect,
onDrop,
@@ -48,6 +54,7 @@ export function NetworkCanvas({
onNodeContextMenu,
onPaneContextMenu,
onPaneClick: onPaneClickProp,
interactionMode = 'select',
}: NetworkCanvasProps) {
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length === 1) {
@@ -75,13 +82,22 @@ export function NetworkCanvas({
}, [])
return (
<div className="relative h-full w-full" onDragLeave={onDragLeave}>
<div
className="relative h-full w-full"
onDragLeave={onDragLeave}
onMouseDownCapture={(event) => {
if (event.button === 1) {
event.preventDefault()
}
}}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onSelectionChange={handleSelectionChange}
onPaneClick={handlePaneClick}
onDrop={onDrop}
@@ -91,12 +107,23 @@ export function NetworkCanvas({
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{ type: 'connection' }}
edgesReconnectable
connectOnClick={interactionMode === 'connect'}
reconnectRadius={20}
connectionRadius={24}
deleteKeyCode={['Backspace', 'Delete']}
multiSelectionKeyCode="Shift"
panOnDrag={interactionMode === 'pan' ? [0, 1] : [1]}
selectionOnDrag={interactionMode === 'select'}
panActivationKeyCode="Space"
snapToGrid={true}
snapGrid={[20, 20]}
fitView
className="bg-page"
className={cn(
'bg-page',
interactionMode === 'pan' && 'cursor-grab active:cursor-grabbing',
interactionMode === 'connect' && 'rf-connect-mode cursor-crosshair',
)}
>
<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" />

View File

@@ -31,6 +31,7 @@ function getEdgePath(routing: string | null | undefined, props: EdgeProps) {
}
if (routing === 'curved') return getBezierPath(base)
if (routing === 'step') return getSmoothStepPath(base)
if (routing === 'orthogonal') return getSmoothStepPath({ ...base, borderRadius: 0 })
return getStraightPath(base)
}
@@ -53,7 +54,7 @@ function ConnectionEdgeComponent(props: EdgeProps) {
{props.label && (
<EdgeLabelRenderer>
<div
className="nodrag nopan rounded bg-page px-1.5 py-0.5 text-[10px] text-muted-foreground"
className="nodrag nopan rounded border border-default bg-card px-1.5 py-0.5 text-[10px] text-muted-foreground shadow-sm"
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,

View File

@@ -33,6 +33,11 @@ export function useCanvasShortcuts({
setEdges,
setIsDirty,
canvasRef,
onUndo,
onRedo,
onNudge,
onSetMode,
onToggleShortcuts,
}: {
nodes: Node[]
edges: Edge[]
@@ -40,6 +45,11 @@ export function useCanvasShortcuts({
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
setIsDirty: (dirty: boolean) => void
canvasRef: React.RefObject<HTMLDivElement | null>
onUndo: () => void
onRedo: () => void
onNudge: (dx: number, dy: number) => void
onSetMode: (mode: 'select' | 'pan' | 'connect') => void
onToggleShortcuts: () => void
}) {
const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
const clipboardRef = useRef<ClipboardData | null>(null)
@@ -211,6 +221,45 @@ export function useCanvasShortcuts({
const ctrl = e.ctrlKey || e.metaKey
// Undo: Ctrl+Z / Cmd+Z
if (e.key === 'z' && ctrl && !e.shiftKey) {
e.preventDefault()
onUndo()
return
}
// Redo: Ctrl+Y or Ctrl+Shift+Z
if ((e.key === 'y' && ctrl) || (e.key === 'z' && ctrl && e.shiftKey)) {
e.preventDefault()
onRedo()
return
}
// Arrow key nudging
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault()
const delta = e.shiftKey ? 10 : 1
switch (e.key) {
case 'ArrowUp': onNudge(0, -delta); break
case 'ArrowDown': onNudge(0, delta); break
case 'ArrowLeft': onNudge(-delta, 0); break
case 'ArrowRight': onNudge( delta, 0); break
}
return
}
// Mode shortcuts: V = select, H = pan, C = connect
if (!ctrl && e.key === 'v') {
onSetMode('select')
return
}
if (!ctrl && e.key === 'h') {
onSetMode('pan')
return
}
if (!ctrl && e.key === 'c') {
onSetMode('connect')
return
}
if (ctrl && e.key === 'c') {
e.preventDefault()
copyNodes()
@@ -232,12 +281,15 @@ export function useCanvasShortcuts({
} else if (e.key === '[' && !ctrl) {
e.preventDefault()
sendSelectedToBack()
} else if (e.key === '?' && !ctrl) {
e.preventDefault()
onToggleShortcuts()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack])
}, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts])
return {
copyNodes,

View File

@@ -0,0 +1,206 @@
import { useCallback } from 'react'
import type { Node, Edge } from '@xyflow/react'
interface UseDiagramCommandsParams {
nodes: Node[]
edges: Edge[]
pushHistory: (nodes: Node[], edges: Edge[]) => void
setNodes: React.Dispatch<React.SetStateAction<Node[]>>
}
export function useDiagramCommands({
nodes,
edges,
pushHistory,
setNodes,
}: UseDiagramCommandsParams) {
const selectedNodes = nodes.filter(n => n.selected)
// ── Alignment ──────────────────────────────────────────────────────────
const alignLeft = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minX = Math.min(...selectedNodes.map(n => n.position.x))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, x: minX } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignRight = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, x: maxX - (n.measured?.width ?? 100) } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignCenterH = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minX = Math.min(...selectedNodes.map(n => n.position.x))
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100)))
const centerX = (minX + maxX) / 2
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, x: centerX - (n.measured?.width ?? 100) / 2 } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignTop = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minY = Math.min(...selectedNodes.map(n => n.position.y))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, y: minY } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignBottom = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, y: maxY - (n.measured?.height ?? 100) } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const alignCenterV = useCallback(() => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const minY = Math.min(...selectedNodes.map(n => n.position.y))
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100)))
const centerY = (minY + maxY) / 2
setNodes(prev => prev.map(n =>
n.selected ? { ...n, position: { ...n.position, y: centerY - (n.measured?.height ?? 100) / 2 } } : n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
// ── Distribution ───────────────────────────────────────────────────────
const distributeHorizontally = useCallback(() => {
if (selectedNodes.length < 3) return
pushHistory(nodes, edges)
const sorted = [...selectedNodes].sort((a, b) => a.position.x - b.position.x)
const minX = sorted[0].position.x
const maxX = sorted[sorted.length - 1].position.x + (sorted[sorted.length - 1].measured?.width ?? 100)
const totalWidth = sorted.reduce((sum, n) => sum + (n.measured?.width ?? 100), 0)
const gap = (maxX - minX - totalWidth) / (sorted.length - 1)
let cursor = minX
const positions: Record<string, number> = {}
for (const n of sorted) {
positions[n.id] = cursor
cursor += (n.measured?.width ?? 100) + gap
}
setNodes(prev => prev.map(n =>
n.selected && positions[n.id] !== undefined
? { ...n, position: { ...n.position, x: positions[n.id] } }
: n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const distributeVertically = useCallback(() => {
if (selectedNodes.length < 3) return
pushHistory(nodes, edges)
const sorted = [...selectedNodes].sort((a, b) => a.position.y - b.position.y)
const minY = sorted[0].position.y
const maxY = sorted[sorted.length - 1].position.y + (sorted[sorted.length - 1].measured?.height ?? 100)
const totalHeight = sorted.reduce((sum, n) => sum + (n.measured?.height ?? 100), 0)
const gap = (maxY - minY - totalHeight) / (sorted.length - 1)
let cursor = minY
const positions: Record<string, number> = {}
for (const n of sorted) {
positions[n.id] = cursor
cursor += (n.measured?.height ?? 100) + gap
}
setNodes(prev => prev.map(n =>
n.selected && positions[n.id] !== undefined
? { ...n, position: { ...n.position, y: positions[n.id] } }
: n
))
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
// ── Helpers ────────────────────────────────────────────────────────────
const canAlign = selectedNodes.length >= 2
const canDistribute = selectedNodes.length >= 3
// ── Grouping ───────────────────────────────────────────────────────────
const groupSelection = useCallback((groupType: string = 'custom') => {
if (selectedNodes.length < 2) return
pushHistory(nodes, edges)
const PADDING = 24
const minX = Math.min(...selectedNodes.map(n => n.position.x)) - PADDING
const minY = Math.min(...selectedNodes.map(n => n.position.y)) - PADDING
const maxX = Math.max(...selectedNodes.map(n => n.position.x + (n.measured?.width ?? 100))) + PADDING
const maxY = Math.max(...selectedNodes.map(n => n.position.y + (n.measured?.height ?? 100))) + PADDING
const groupId = `group-${Date.now()}`
const groupNode: Node = {
id: groupId,
type: 'group',
position: { x: minX, y: minY },
style: { width: maxX - minX, height: maxY - minY },
data: { label: groupType.charAt(0).toUpperCase() + groupType.slice(1), groupType },
selected: false,
}
setNodes(prev => [
groupNode,
...prev.map(n =>
n.selected
? {
...n,
parentId: groupId,
extent: 'parent' as const,
position: { x: n.position.x - minX, y: n.position.y - minY },
selected: false,
}
: n
),
])
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const ungroupSelection = useCallback(() => {
const selectedGroups = selectedNodes.filter(n => n.type === 'group')
if (selectedGroups.length === 0) return
pushHistory(nodes, edges)
const groupIds = new Set(selectedGroups.map(g => g.id))
setNodes(prev => {
const groupPositions: Record<string, { x: number; y: number }> = {}
for (const n of prev) {
if (groupIds.has(n.id)) groupPositions[n.id] = n.position
}
return prev
.filter(n => !groupIds.has(n.id))
.map(n => {
if (n.parentId && groupIds.has(n.parentId)) {
const gPos = groupPositions[n.parentId] ?? { x: 0, y: 0 }
return {
...n,
parentId: undefined,
extent: undefined,
position: { x: gPos.x + n.position.x, y: gPos.y + n.position.y },
}
}
return n
})
})
}, [nodes, edges, selectedNodes, pushHistory, setNodes])
const canGroup = selectedNodes.length >= 2 && !selectedNodes.some(n => n.type === 'group')
const canUngroup = selectedNodes.some(n => n.type === 'group')
return {
alignLeft,
alignRight,
alignCenterH,
alignTop,
alignBottom,
alignCenterV,
distributeHorizontally,
distributeVertically,
canAlign,
canDistribute,
selectedNodes,
groupSelection,
ungroupSelection,
canGroup,
canUngroup,
}
}

View File

@@ -1,10 +1,11 @@
import { memo } from 'react'
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
import { memo, useState, useRef, useEffect } from 'react'
import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
import { BaseNode, BaseNodeHeader, 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 { getDeviceRenderConfig, CATEGORY_LABELS } from './deviceRegistry'
import { cn } from '@/lib/utils'
import type { DeviceProperties } from '@/types'
export interface DeviceNodeData {
@@ -29,9 +30,9 @@ 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) {
function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) {
const nodeData = data as unknown as DeviceNodeData
const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const { icon: Icon, color, accentClass, surfaceClass, category } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category)
const status = (nodeData.properties?.status || 'unknown') as NodeStatus
const ip = nodeData.properties?.ip
const props = nodeData.properties || {}
@@ -46,6 +47,25 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
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 metaPx = Math.max(8, Math.min(11, Math.round(scale * 8)))
const iconPlateSize = Math.round(Math.max(34, Math.min(82, scale * 50)))
const [editing, setEditing] = useState(false)
const [labelValue, setLabelValue] = useState(nodeData.label ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const { updateNodeData } = useReactFlow()
useEffect(() => {
if (editing) {
inputRef.current?.focus()
inputRef.current?.select()
}
}, [editing])
// Sync if data.label changes externally (e.g. undo/redo)
useEffect(() => {
if (!editing) setLabelValue(nodeData.label ?? '')
}, [nodeData.label, editing])
const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes
@@ -64,16 +84,70 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) {
<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>
<BaseNode className="group h-full w-full bg-card">
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b opacity-80', surfaceClass)} />
<BaseNodeHeader className="flex h-full flex-col items-center justify-center gap-2 px-2 py-3">
<div
className={cn(
'relative flex items-center justify-center rounded-xl border transition-colors',
accentClass,
)}
style={{ width: iconPlateSize, height: iconPlateSize }}
>
<div className="absolute inset-[4px] rounded-[10px] border border-white/[0.06] bg-sidebar/50" />
<div className="relative z-10">
<Icon size={iconPx} style={{ color }} />
</div>
</div>
{editing ? (
<input
ref={inputRef}
value={labelValue}
onChange={e => setLabelValue(e.target.value)}
onBlur={() => {
setEditing(false)
if (labelValue !== nodeData.label) {
updateNodeData(id, { ...nodeData, label: labelValue })
}
}}
onKeyDown={e => {
if (e.key === 'Enter') inputRef.current?.blur()
if (e.key === 'Escape') {
setLabelValue(nodeData.label ?? '')
setEditing(false)
}
e.stopPropagation()
}}
style={{ fontSize: labelPx }}
className="bg-transparent border-none outline-none text-center text-primary font-medium w-4/5"
/>
) : (
<span
style={{ fontSize: labelPx }}
className="max-w-[88%] cursor-default text-center font-medium leading-tight text-primary line-clamp-2"
onDoubleClick={e => {
e.stopPropagation()
setEditing(true)
}}
>
{labelValue}
</span>
)}
<span
style={{ fontSize: metaPx }}
className="text-[9px] uppercase tracking-[0.16em] text-muted"
>
{CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')}
</span>
</BaseNodeHeader>
{ip && (
<BaseNodeContent className="items-center pt-0 pb-1">
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
<BaseNodeContent className="items-center pt-0 pb-2">
<span
className="rounded-full border border-default bg-page/70 px-2 py-0.5 font-mono text-muted-foreground"
style={{ fontSize: ipPx }}
>
{ip}
</span>
</BaseNodeContent>
)}
<BaseHandle type="target" position={Position.Top} />

View File

@@ -0,0 +1,86 @@
import { memo, useState, useRef, useEffect } from 'react'
import { NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
import type { GroupNodeData } from '@/types/network-diagram'
const GROUP_COLORS: Record<string, string> = {
subnet: '#60a5fa',
vlan: '#a78bfa',
site: '#34d399',
dmz: '#f87171',
custom: '#94a3b8',
}
const GroupNodeComponent = ({ data, selected, id }: NodeProps) => {
const groupData = data as GroupNodeData
const color = GROUP_COLORS[groupData.groupType] ?? GROUP_COLORS.custom
const [editing, setEditing] = useState(false)
const [labelValue, setLabelValue] = useState(groupData.label ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const { updateNodeData } = useReactFlow()
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
// Sync if external data.label changes
useEffect(() => {
if (!editing) setLabelValue(groupData.label ?? '')
}, [groupData.label, editing])
const handleLabelCommit = () => {
setEditing(false)
if (labelValue !== groupData.label) {
updateNodeData(id, { ...groupData, label: labelValue })
}
}
return (
<>
<NodeResizer
isVisible={selected}
minWidth={120}
minHeight={80}
lineStyle={{ border: `1px solid ${color}` }}
handleStyle={{ width: 8, height: 8, borderRadius: 2, background: color }}
/>
<div
className="w-full h-full rounded-lg relative"
style={{
border: `1.5px dashed ${color}`,
background: `${color}0d`,
boxSizing: 'border-box',
}}
>
<div className="absolute top-0 left-2 -translate-y-full pb-0.5">
{editing ? (
<input
ref={inputRef}
value={labelValue}
onChange={e => setLabelValue(e.target.value)}
onBlur={handleLabelCommit}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit()
e.stopPropagation()
}}
className="rounded-sm px-1.5 py-0.5 text-[11px] font-semibold bg-card/90 border-none outline-none min-w-[40px] max-w-[200px]"
style={{ color }}
/>
) : (
<span
className="inline-block rounded-sm bg-card/90 px-1.5 py-0.5 text-[11px] font-semibold cursor-text select-none tracking-wide"
style={{ color }}
onDoubleClick={() => setEditing(true)}
>
{labelValue || groupData.groupType}
</span>
)}
</div>
</div>
</>
)
}
GroupNodeComponent.displayName = 'GroupNode'
export const GroupNode = memo(GroupNodeComponent)
export default GroupNode

View File

@@ -9,6 +9,9 @@ import {
export interface DeviceRenderConfig {
icon: LucideIcon
color: string
accentClass: string
surfaceClass: string
category: string
}
// Category-semantic color palette — each color carries meaning:
@@ -27,62 +30,107 @@ export const STORAGE_COLOR = '#a78bfa'
export const CLOUD_COLOR = '#67e8f9'
export const INFRA_COLOR = '#94a3b8'
const CATEGORY_STYLES: Record<string, Pick<DeviceRenderConfig, 'accentClass' | 'surfaceClass'>> = {
network: {
accentClass: 'border-sky-400/40 bg-sky-400/12 text-sky-300',
surfaceClass: 'from-sky-400/12 via-sky-400/4 to-transparent',
},
security: {
accentClass: 'border-rose-400/40 bg-rose-400/12 text-rose-300',
surfaceClass: 'from-rose-400/12 via-rose-400/4 to-transparent',
},
compute: {
accentClass: 'border-emerald-400/40 bg-emerald-400/12 text-emerald-300',
surfaceClass: 'from-emerald-400/12 via-emerald-400/4 to-transparent',
},
storage: {
accentClass: 'border-violet-400/40 bg-violet-400/12 text-violet-300',
surfaceClass: 'from-violet-400/12 via-violet-400/4 to-transparent',
},
cloud: {
accentClass: 'border-cyan-400/40 bg-cyan-400/12 text-cyan-300',
surfaceClass: 'from-cyan-400/12 via-cyan-400/4 to-transparent',
},
endpoint: {
accentClass: 'border-amber-400/40 bg-amber-400/12 text-amber-300',
surfaceClass: 'from-amber-400/12 via-amber-400/4 to-transparent',
},
infrastructure: {
accentClass: 'border-slate-400/40 bg-slate-300/10 text-slate-300',
surfaceClass: 'from-slate-300/10 via-slate-300/4 to-transparent',
},
}
function makeConfig(
icon: LucideIcon,
color: string,
category: string,
): DeviceRenderConfig {
return {
icon,
color,
category,
accentClass: CATEGORY_STYLES[category]?.accentClass ?? CATEGORY_STYLES.infrastructure.accentClass,
surfaceClass: CATEGORY_STYLES[category]?.surfaceClass ?? CATEGORY_STYLES.infrastructure.surfaceClass,
}
}
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 },
'router': makeConfig(Router, NETWORK_COLOR, 'network'),
'switch': makeConfig(Network, NETWORK_COLOR, 'network'),
'access-point': makeConfig(Wifi, NETWORK_COLOR, 'network'),
'load-balancer': makeConfig(Gauge, NETWORK_COLOR, 'network'),
// Security
'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
'firewall': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'),
'badge-reader': makeConfig(KeyRound, SECURITY_COLOR, 'security'),
// Compute
'server': { icon: Server, color: COMPUTE_COLOR },
'vm': { icon: Boxes, color: COMPUTE_COLOR },
'container': { icon: Package, color: COMPUTE_COLOR },
'server': makeConfig(Server, COMPUTE_COLOR, 'compute'),
'vm': makeConfig(Boxes, COMPUTE_COLOR, 'compute'),
'container': makeConfig(Package, COMPUTE_COLOR, 'compute'),
// Storage
'nas': { icon: Database, color: STORAGE_COLOR },
'san': { icon: HardDrive, color: STORAGE_COLOR },
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
'nas': makeConfig(Database, STORAGE_COLOR, 'storage'),
'san': makeConfig(HardDrive, STORAGE_COLOR, 'storage'),
'cloud-storage': makeConfig(CloudCog, STORAGE_COLOR, 'storage'),
// 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 },
'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
'aws': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
'azure': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
'gcp': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
'isp': makeConfig(Globe, CLOUD_COLOR, 'cloud'),
// 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 },
'workstation': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'),
'laptop': makeConfig(Laptop, ENDPOINT_COLOR, 'endpoint'),
'tablet': makeConfig(Tablet, ENDPOINT_COLOR, 'endpoint'),
'phone': makeConfig(Smartphone, ENDPOINT_COLOR, 'endpoint'),
'printer': makeConfig(Printer, ENDPOINT_COLOR, 'endpoint'),
// 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 },
'ups': makeConfig(BatteryCharging, INFRA_COLOR, 'infrastructure'),
'pdu': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'),
'rack': makeConfig(RectangleVertical, INFRA_COLOR, 'infrastructure'),
'patch-panel': makeConfig(Cable, INFRA_COLOR, 'infrastructure'),
'camera': makeConfig(Camera, INFRA_COLOR, 'infrastructure'),
'nvr': makeConfig(Video, INFRA_COLOR, 'infrastructure'),
'iot': makeConfig(Radio, INFRA_COLOR, 'infrastructure'),
}
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 },
'network': makeConfig(Router, NETWORK_COLOR, 'network'),
'compute': makeConfig(Server, COMPUTE_COLOR, 'compute'),
'storage': makeConfig(Database, STORAGE_COLOR, 'storage'),
'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
'endpoint': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'),
'infrastructure': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'),
'security': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'),
}
const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR }
const FALLBACK: DeviceRenderConfig = makeConfig(Cpu, INFRA_COLOR, 'infrastructure')
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]

View File

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

View File

@@ -151,22 +151,22 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
</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' },
{ slug: 'subnet', label: 'Subnet', color: '#60a5fa' },
{ slug: 'vlan', label: 'VLAN', color: '#a78bfa' },
{ slug: 'site', label: 'Site', color: '#34d399' },
{ slug: 'dmz', label: 'DMZ', color: '#f87171' },
].map(item => (
<div
key={item.slug}
draggable
onDragStart={e => {
e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item))
e.dataTransfer.setData('application/reactflow-group', JSON.stringify({ slug: item.slug, label: item.label }))
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" />
<LayoutGrid size={14} style={{ color: item.color }} />
<span>{item.label}</span>
</div>
))}

View File

@@ -1,5 +1,11 @@
import { useCallback, useState, useEffect } from 'react'
import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'lucide-react'
import {
Trash2, Minus, Spline, GitBranch, CornerUpRight, BringToFront, SendToBack,
AlignStartVertical, AlignCenterHorizontal, AlignEndVertical,
AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal,
AlignHorizontalSpaceAround, AlignVerticalSpaceAround,
BoxSelect, Ungroup, MousePointer,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import type { DeviceProperties, DiagramEdge } from '@/types'
import type { Node, Edge } from '@xyflow/react'
@@ -15,6 +21,21 @@ interface PropertiesPanelProps {
onSendToBack: (nodeId: string) => void
onDeleteNode: (nodeId: string) => void
onDeleteEdge: (edgeId: string) => void
selectedNodeCount: number
onAlignLeft: () => void
onAlignRight: () => void
onAlignCenterH: () => void
onAlignTop: () => void
onAlignBottom: () => void
onAlignCenterV: () => void
onDistributeH: () => void
onDistributeV: () => void
canAlign: boolean
canDistribute: boolean
canGroup: boolean
canUngroup: boolean
onGroupSelection: (groupType: string) => void
onUngroupSelection: () => void
}
type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown'
@@ -68,6 +89,14 @@ function SectionDivider({ label }: { label: string }) {
)
}
const GROUP_TYPES = [
{ value: 'subnet', label: 'Subnet' },
{ value: 'vlan', label: 'VLAN' },
{ value: 'site', label: 'Site' },
{ value: 'dmz', label: 'DMZ' },
{ value: 'custom', label: 'Custom' },
]
export function PropertiesPanel({
selectedNode,
selectedEdge,
@@ -78,8 +107,24 @@ export function PropertiesPanel({
onSendToBack,
onDeleteNode,
onDeleteEdge,
selectedNodeCount,
onAlignLeft,
onAlignRight,
onAlignCenterH,
onAlignTop,
onAlignBottom,
onAlignCenterV,
onDistributeH,
onDistributeV,
canAlign,
canDistribute,
canGroup,
canUngroup,
onGroupSelection,
onUngroupSelection,
}: PropertiesPanelProps) {
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [pendingGroupType, setPendingGroupType] = useState('subnet')
// Reset confirm state whenever the selection changes
// eslint-disable-next-line react-hooks/set-state-in-effect
@@ -98,14 +143,107 @@ export function PropertiesPanel({
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
}, [selectedNode, onNodeUpdate])
if (!selectedNode && !selectedEdge && selectedNodeCount >= 2) {
return (
<div className="w-[260px] border-l border-default bg-sidebar flex flex-col">
<div className="px-4 py-3 border-b border-default">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{selectedNodeCount} nodes selected
</div>
</div>
<div className="p-3 flex flex-col gap-4">
{canAlign && (
<div>
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Align</div>
<div className="grid grid-cols-3 gap-1">
{([
{ label: 'Left', icon: AlignStartVertical, action: onAlignLeft },
{ label: 'Center', icon: AlignCenterHorizontal, action: onAlignCenterH },
{ label: 'Right', icon: AlignEndVertical, action: onAlignRight },
{ label: 'Top', icon: AlignStartHorizontal, action: onAlignTop },
{ label: 'Middle', icon: AlignCenterVertical, action: onAlignCenterV },
{ label: 'Bottom', icon: AlignEndHorizontal, action: onAlignBottom },
] as const).map(({ label, icon: Icon, action }) => (
<button
key={label}
onClick={action}
title={`Align ${label}`}
className="flex flex-col items-center gap-1 p-2 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary"
>
<Icon size={14} />
<span className="text-[9px]">{label}</span>
</button>
))}
</div>
</div>
)}
{canDistribute && (
<div>
<div className="text-[10px] font-medium text-muted uppercase tracking-wider mb-2">Distribute</div>
<div className="grid grid-cols-2 gap-1">
<button
onClick={onDistributeH}
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<AlignHorizontalSpaceAround size={13} /> Horizontal
</button>
<button
onClick={onDistributeV}
className="flex items-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<AlignVerticalSpaceAround size={13} /> Vertical
</button>
</div>
</div>
)}
{(canGroup || canUngroup) && (
<div className="flex flex-col gap-2">
<div className="text-[10px] font-medium text-muted uppercase tracking-wider">Grouping</div>
{canGroup && (
<div className="flex flex-col gap-1.5">
<select
value={pendingGroupType}
onChange={e => setPendingGroupType(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"
>
{GROUP_TYPES.map(gt => (
<option key={gt.value} value={gt.value}>{gt.label}</option>
))}
</select>
<button
onClick={() => onGroupSelection(pendingGroupType)}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<BoxSelect size={13} /> Group into {GROUP_TYPES.find(g => g.value === pendingGroupType)?.label}
</button>
</div>
)}
{canUngroup && (
<button
onClick={onUngroupSelection}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 rounded bg-elevated hover:bg-card border border-default hover:border-hover transition-colors text-muted-foreground hover:text-primary text-xs"
>
<Ungroup size={13} /> Ungroup
</button>
)}
</div>
)}
</div>
</div>
)
}
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
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-6">
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg border border-default bg-elevated text-muted-foreground">
<MousePointer size={15} />
</div>
<p className="text-center text-xs font-medium text-muted-foreground">
Select a device or connection
</p>
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
Hover a device to preview its info
<p className="mt-1 text-center text-[10px] text-muted-foreground/50 leading-relaxed">
Properties appear here. Hover a device to see a quick summary.
</p>
</div>
)
@@ -122,6 +260,9 @@ export function PropertiesPanel({
<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="rounded border border-default bg-elevated/40 px-2.5 py-2 text-[10px] text-muted-foreground">
Drag either end of the line on the canvas to reconnect it to a different asset.
</div>
<div className="flex flex-col gap-1">
<FieldLabel>Label</FieldLabel>
<FieldInput
@@ -179,6 +320,7 @@ export function PropertiesPanel({
{ value: null, icon: Minus, label: 'Straight' },
{ value: 'curved', icon: Spline, label: 'Curved' },
{ value: 'step', icon: GitBranch, label: 'Step' },
{ value: 'orthogonal', icon: CornerUpRight, label: 'Ortho' },
] as const).map(({ value, icon: Icon, label }) => {
const routing = (edgeData.routing as string | null | undefined) ?? null
const active = routing === value

View File

@@ -9,8 +9,9 @@ export function BaseHandle({ className, children, ...props }: ComponentProps<typ
<Handle
{...props}
className={cn(
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
'opacity-0 group-hover:opacity-100',
'h-3 w-3 rounded-full border border-accent/60 bg-card transition-opacity',
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
'[.rf-connect-mode_&]:opacity-100',
className,
)}
>

View File

@@ -5,8 +5,8 @@ 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',
'bg-card text-heading relative overflow-hidden rounded-xl border border-default',
'transition-colors hover:border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
'in-[.selected]:border-accent',
className,
)}

View File

@@ -11,9 +11,9 @@ const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
}
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)]',
online: 'shadow-[0_0_6px_rgba(52,211,153,0.15)]',
offline: 'shadow-[0_0_6px_rgba(248,113,113,0.15)]',
degraded: 'shadow-[0_0_6px_rgba(250,204,21,0.15)]',
unknown: '',
}
@@ -31,7 +31,7 @@ export function NodeStatusIndicator({ status = 'unknown', children, className }:
return (
<div
className={cn(
'rounded-lg border-2 transition-colors',
'w-full h-full rounded-lg border-2 transition-colors',
STATUS_BORDER_COLORS[status],
STATUS_GLOW[status],
className,

View File

@@ -14,20 +14,21 @@ const NodeTooltipContext = createContext<NodeTooltipContextValue>({
hide: () => {},
})
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
export function NodeTooltip({ children, className, ...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>
<div className={cn('w-full h-full', className)} {...props}>{children}</div>
</NodeTooltipContext.Provider>
)
}
export function NodeTooltipTrigger({
children,
className,
onMouseEnter,
onMouseLeave,
...props
@@ -36,6 +37,7 @@ export function NodeTooltipTrigger({
return (
<div
className={cn('w-full h-full', className)}
onMouseEnter={(e) => {
show()
onMouseEnter?.(e)

View File

@@ -445,3 +445,42 @@
scroll-behavior: auto !important;
}
}
/* ── Print / PDF export ───────────────────────────────────────────────── */
@media print {
/* Hide everything that isn't the canvas */
body > * { display: none !important; }
/* Show only the React Flow viewport inside the diagram editor page */
#root { display: block !important; }
#root > * { display: none !important; }
/* The diagram editor mounts as a child of the app shell — target the canvas wrapper */
.react-flow__renderer,
.react-flow__viewport,
.react-flow {
display: block !important;
}
/* Make the canvas fill the printed page */
.react-flow {
position: fixed !important;
inset: 0 !important;
width: 100vw !important;
height: 100vh !important;
background: #ffffff !important;
}
/* Force light backgrounds on nodes so they're readable on white paper */
.react-flow__node {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* Hide UI chrome */
.react-flow__controls,
.react-flow__minimap,
.react-flow__panel {
display: none !important;
}
}

View File

@@ -0,0 +1,99 @@
import type { Node, Edge } from '@xyflow/react'
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
import type { GroupNodeData } from '@/types/network-diagram'
// Maps our device slugs to draw.io Cisco stencil shape styles
const SLUG_TO_DRAWIO_STYLE: Record<string, string> = {
'router': 'shape=mxgraph.cisco.routers.router;',
'switch': 'shape=mxgraph.cisco.switches.layer_3_switch;',
'access-point': 'shape=mxgraph.cisco.misc.access_point;',
'load-balancer': 'shape=mxgraph.cisco.misc.generic_building;',
'firewall': 'shape=mxgraph.cisco.firewalls.firewall;',
'badge-reader': 'shape=mxgraph.cisco.misc.generic_building;',
'server': 'shape=mxgraph.cisco.servers.standard_server;',
'vm': 'shape=mxgraph.cisco.servers.standard_server;',
'container': 'shape=mxgraph.cisco.servers.standard_server;',
'nas': 'shape=mxgraph.cisco.storage.tape_storage_library;',
'san': 'shape=mxgraph.cisco.storage.tape_storage_library;',
'cloud-storage': 'shape=mxgraph.cisco.misc.cloud;',
'cloud': 'shape=mxgraph.cisco.misc.cloud;',
'aws': 'shape=mxgraph.cisco.misc.cloud;',
'azure': 'shape=mxgraph.cisco.misc.cloud;',
'gcp': 'shape=mxgraph.cisco.misc.cloud;',
'isp': 'shape=mxgraph.cisco.misc.cloud;',
'workstation': 'shape=mxgraph.cisco.computers_and_peripherals.pc;',
'laptop': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
'tablet': 'shape=mxgraph.cisco.computers_and_peripherals.laptop;',
'phone': 'shape=mxgraph.cisco.computers_and_peripherals.ip_phone;',
'printer': 'shape=mxgraph.cisco.computers_and_peripherals.printer;',
'ups': 'shape=mxgraph.cisco.misc.generic_building;',
'pdu': 'shape=mxgraph.cisco.misc.generic_building;',
'rack': 'shape=mxgraph.cisco.misc.generic_building;',
'patch-panel': 'shape=mxgraph.cisco.misc.generic_building;',
'camera': 'shape=mxgraph.cisco.misc.generic_building;',
'nvr': 'shape=mxgraph.cisco.misc.generic_building;',
'iot': 'shape=mxgraph.cisco.misc.generic_building;',
}
const BASE_NODE_STYLE =
'sketch=0;html=1;pointerEvents=1;dashed=0;fillColor=#036897;strokeColor=#ffffff;strokeWidth=2;verticalLabelPosition=bottom;verticalAlign=top;align=center;outlineConnect=0;'
const GROUP_STYLE =
'swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;collapsible=0;marginBottom=0;swimlaneHead=0;fillColor=none;'
function esc(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function exportToDrawio(nodes: Node[], edges: Edge[]): string {
const cells: string[] = [
'<mxCell id="0"/>',
'<mxCell id="1" parent="0"/>',
]
for (const node of nodes) {
const w = typeof node.style?.width === 'number' ? node.style.width : (node.measured?.width ?? 120)
const h = typeof node.style?.height === 'number' ? node.style.height : (node.measured?.height ?? 120)
const x = node.position.x
const y = node.position.y
const parentId = node.parentId ?? '1'
if (node.type === 'group') {
const gd = node.data as GroupNodeData
cells.push(
`<mxCell id="${esc(node.id)}" value="${esc(gd.label ?? '')}" style="${GROUP_STYLE}" vertex="1" parent="${esc(parentId)}">` +
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
`</mxCell>`,
)
} else {
const dd = node.data as DeviceNodeData
const slug = dd.deviceType ?? 'server'
const shapeStyle = SLUG_TO_DRAWIO_STYLE[slug] ?? 'rounded=1;whiteSpace=wrap;html=1;'
cells.push(
`<mxCell id="${esc(node.id)}" value="${esc(dd.label ?? '')}" style="${shapeStyle}${BASE_NODE_STYLE}" vertex="1" parent="${esc(parentId)}">` +
`<mxGeometry x="${x}" y="${y}" width="${w}" height="${h}" as="geometry"/>` +
`</mxCell>`,
)
}
}
for (const edge of edges) {
const label = typeof edge.label === 'string' ? edge.label : ''
cells.push(
`<mxCell id="${esc(edge.id)}" value="${esc(label)}" style="edgeStyle=orthogonalEdgeStyle;html=1;" edge="1" source="${esc(edge.source)}" target="${esc(edge.target)}" parent="1">` +
`<mxGeometry relative="1" as="geometry"/>` +
`</mxCell>`,
)
}
const xml =
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<mxGraphModel><root>\n` +
cells.join('\n') +
`\n</root></mxGraphModel>`
return xml
}

View File

@@ -0,0 +1,142 @@
import type { DiagramNode, DiagramEdge } from '@/types/network-diagram'
// Maps draw.io shape identifiers (substrings of style) → our device slugs
const DRAWIO_SHAPE_TO_SLUG: Array<[string, string]> = [
['cisco.routers.router', 'router'],
['cisco.routers', 'router'],
['cisco.switches.layer_3_switch', 'switch'],
['cisco.switches.workgroup_switch', 'switch'],
['cisco.switches', 'switch'],
['cisco.firewalls', 'firewall'],
['cisco.servers', 'server'],
['cisco.computers_and_peripherals.laptop', 'laptop'],
['cisco.computers_and_peripherals.ip_phone', 'phone'],
['cisco.computers_and_peripherals.pc', 'workstation'],
['cisco.computers_and_peripherals.printer', 'printer'],
['cisco.misc.access_point', 'access-point'],
['cisco.misc.cloud', 'cloud'],
['cisco.storage', 'nas'],
['shape=router', 'router'],
['shape=server', 'server'],
['shape=firewall', 'firewall'],
['shape=cloud', 'cloud'],
]
function styleToSlug(style: string): string {
const lower = style.toLowerCase()
for (const [pattern, slug] of DRAWIO_SHAPE_TO_SLUG) {
if (lower.includes(pattern)) return slug
}
return 'server'
}
function isGroup(style: string): boolean {
return style.includes('swimlane') || style.includes('container') || style.includes('group')
}
export interface DrawioImportResult {
nodes: DiagramNode[]
edges: DiagramEdge[]
warnings: string[]
}
export function parseDrawioXml(xmlString: string): DrawioImportResult {
const parser = new DOMParser()
const doc = parser.parseFromString(xmlString, 'application/xml')
const parseError = doc.querySelector('parsererror')
if (parseError) {
throw new Error('Invalid draw.io XML: ' + parseError.textContent?.slice(0, 200))
}
const cells = Array.from(doc.querySelectorAll('mxCell'))
const warnings: string[] = []
const nodes: DiagramNode[] = []
const edges: DiagramEdge[] = []
const geoMap = new Map<string, { x: number; y: number; width: number; height: number }>()
for (const cell of cells) {
const geo = cell.querySelector('mxGeometry')
if (geo) {
geoMap.set(cell.getAttribute('id') ?? '', {
x: parseFloat(geo.getAttribute('x') ?? '0'),
y: parseFloat(geo.getAttribute('y') ?? '0'),
width: parseFloat(geo.getAttribute('width') ?? '120'),
height: parseFloat(geo.getAttribute('height') ?? '120'),
})
}
}
const groupIds = new Set<string>()
for (const cell of cells) {
const id = cell.getAttribute('id') ?? ''
if (id === '0' || id === '1') continue
const isEdge = cell.getAttribute('edge') === '1'
const isVertex = cell.getAttribute('vertex') === '1'
const style = cell.getAttribute('style') ?? ''
const value = cell.getAttribute('value') ?? ''
const parent = cell.getAttribute('parent') ?? '1'
const geo = geoMap.get(id)
if (isEdge) {
const source = cell.getAttribute('source') ?? ''
const target = cell.getAttribute('target') ?? ''
if (!source || !target) {
warnings.push(`Edge "${id}" skipped — missing source or target`)
continue
}
edges.push({
id,
source,
target,
label: value || null,
connectionType: 'ethernet',
speed: null,
notes: null,
routing: null,
})
continue
}
if (isVertex && geo) {
if (isGroup(style)) {
groupIds.add(id)
nodes.push({
id,
type: 'subnet',
label: value || 'Group',
position: { x: geo.x, y: geo.y },
properties: {
hostname: null, ip: null, subnet: null, vendor: null,
model: null, role: null, vlan: null, notes: null, status: 'unknown',
},
nodeType: 'group',
style: { width: geo.width, height: geo.height },
})
} else {
const slug = styleToSlug(style)
const parentId = parent !== '1' && groupIds.has(parent) ? parent : undefined
nodes.push({
id,
type: slug,
label: value || slug,
position: { x: geo.x, y: geo.y },
properties: {
hostname: null, ip: null, subnet: null, vendor: null,
model: null, role: null, vlan: null, notes: null, status: 'unknown',
},
...(parentId ? { parentId } : {}),
style: { width: geo.width, height: geo.height },
})
}
}
}
if (nodes.length === 0) {
warnings.push('No nodes were found in this draw.io file. Only basic shapes and Cisco stencil shapes are supported.')
}
return { nodes, edges, warnings }
}

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useState, useCallback, useEffect, useRef, useReducer } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
ReactFlowProvider,
useNodesState,
useEdgesState,
addEdge,
reconnectEdge,
useReactFlow,
getNodesBounds,
getViewportForBounds,
@@ -17,16 +18,25 @@ import '@xyflow/react/dist/style.css'
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
import { DiagramHeader } from '@/components/network/DiagramHeader'
import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands'
import { DiagramHeader, type InteractionMode } from '@/components/network/DiagramHeader'
import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
import { KeyboardShortcutsOverlay } from '@/components/network/KeyboardShortcutsOverlay'
import { networkDiagramsApi, deviceTypesApi } from '@/api'
import { toast } from '@/lib/toast'
import { exportToDrawio } from '@/lib/drawio-export'
import { parseDrawioXml } from '@/lib/drawio-import'
import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
function normalizeZOrder(nodes: Node[]): Node[] {
const sorted = [...nodes].sort((a, b) => ((a.zIndex ?? 0) - (b.zIndex ?? 0)))
return sorted.map((n, i) => ({ ...n, zIndex: i + 1 }))
}
type ContextMenuState = {
type: 'node' | 'canvas'
position: { x: number; y: number }
@@ -63,13 +73,78 @@ function DiagramEditorInner() {
const [loading, setLoading] = useState(!!id)
const [isDragOver, setIsDragOver] = useState(false)
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
const [showShortcuts, setShowShortcuts] = useState(false)
const canvasRef = useRef<HTMLDivElement | null>(null)
const drawioImportRef = useRef<HTMLInputElement>(null)
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
// History
const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([])
const historyIndex = useRef<number>(-1)
const MAX_HISTORY = 50
const [, forceHistoryUpdate] = useReducer((x: number) => x + 1, 0)
const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => {
historyStack.current = historyStack.current.slice(0, historyIndex.current + 1)
historyStack.current.push({
nodes: JSON.parse(JSON.stringify(currentNodes)),
edges: JSON.parse(JSON.stringify(currentEdges)),
})
if (historyStack.current.length > MAX_HISTORY) {
historyStack.current.shift()
} else {
historyIndex.current += 1
}
forceHistoryUpdate()
}, [forceHistoryUpdate])
const undo = useCallback(() => {
if (historyIndex.current <= 0) return
historyIndex.current -= 1
const snapshot = historyStack.current[historyIndex.current]
setNodes(snapshot.nodes)
setEdges(snapshot.edges)
setIsDirty(true)
forceHistoryUpdate()
}, [setNodes, setEdges, forceHistoryUpdate])
const redo = useCallback(() => {
if (historyIndex.current >= historyStack.current.length - 1) return
historyIndex.current += 1
const snapshot = historyStack.current[historyIndex.current]
setNodes(snapshot.nodes)
setEdges(snapshot.edges)
setIsDirty(true)
forceHistoryUpdate()
}, [setNodes, setEdges, forceHistoryUpdate])
const canUndo = historyIndex.current > 0
const canRedo = historyIndex.current < historyStack.current.length - 1
const diagramCommands = useDiagramCommands({
nodes,
edges,
pushHistory,
setNodes,
})
const onNudge = useCallback((dx: number, dy: number) => {
const selected = nodes.filter(n => n.selected)
if (selected.length === 0) return
pushHistory(nodes, edges)
setNodes(prev => prev.map(n =>
n.selected
? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } }
: n
))
}, [nodes, edges, pushHistory, setNodes])
const {
copyNodes,
pasteNodes,
@@ -84,6 +159,11 @@ function DiagramEditorInner() {
setEdges,
setIsDirty: (v: boolean) => setIsDirty(v),
canvasRef,
onUndo: undo,
onRedo: redo,
onNudge,
onSetMode: setInteractionMode,
onToggleShortcuts: () => setShowShortcuts(v => !v),
})
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
@@ -137,6 +217,7 @@ function DiagramEditorInner() {
type: 'device',
position: n.position,
style: n.style || { width: 120, height: 120 },
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
data: {
label: n.label,
deviceType: n.type,
@@ -161,6 +242,37 @@ function DiagramEditorInner() {
}))
)
setLastSavedAt(new Date(diagram.updated_at))
// Initialize history after load
const loadedNodes = diagram.nodes.map(n => {
if (n.nodeType === 'group') {
return {
id: n.id,
type: 'group' as const,
position: n.position,
style: n.style || { width: 300, height: 200 },
data: { label: n.label, groupType: n.type },
}
}
return {
id: n.id,
type: 'device' as const,
position: n.position,
style: n.style || { width: 120, height: 120 },
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
data: { label: n.label, deviceType: n.type, properties: n.properties } satisfies DeviceNodeData,
}
})
const loadedEdges = diagram.edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
type: 'connection' as const,
label: e.label || undefined,
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes, routing: e.routing ?? null },
}))
historyStack.current = []
historyIndex.current = -1
pushHistory(loadedNodes, loadedEdges)
} catch {
toast.error('Failed to load diagram')
navigate('/network-diagrams')
@@ -169,7 +281,7 @@ function DiagramEditorInner() {
}
})()
return () => { cancelled = true }
}, [id, navigate, setNodes, setEdges])
}, [id, navigate, setNodes, setEdges, pushHistory])
const serializeNodes = useCallback((): DiagramNode[] => {
return getNodes().map(n => {
@@ -197,6 +309,7 @@ function DiagramEditorInner() {
position: n.position,
properties: data.properties,
style: { width: dw, height: dh },
...(n.parentId ? { parentId: n.parentId } : {}),
}
})
}, [getNodes])
@@ -212,7 +325,7 @@ function DiagramEditorInner() {
connectionType: d.connectionType as string || 'ethernet',
speed: d.speed as string || null,
notes: d.notes as string || null,
routing: d.routing as string || null,
routing: (d.routing as DiagramEdge['routing']) || null,
}
})
}, [edges])
@@ -228,21 +341,51 @@ function DiagramEditorInner() {
nodes: serializeNodes(),
edges: serializeEdges(),
}
let savedId: string | null = diagramIdRef.current
if (diagramIdRef.current) {
await networkDiagramsApi.update(diagramIdRef.current, payload)
} else {
const created = await networkDiagramsApi.create(payload)
savedId = created.id
setDiagramId(created.id)
navigate(`/network-diagrams/${created.id}`, { replace: true })
}
setIsDirty(false)
setLastSavedAt(new Date())
// Generate thumbnail in the background — don't block save UX on failure
if (savedId && nodes.length > 0) {
try {
const { toPng } = await import('html-to-image')
const THUMB_W = 480
const THUMB_H = 300
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, THUMB_W, THUMB_H, 0.5, 2, 0.1)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (flowEl) {
const dataUrl = await toPng(flowEl, {
backgroundColor: '#16181f',
width: THUMB_W,
height: THUMB_H,
style: {
width: `${THUMB_W}px`,
height: `${THUMB_H}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
await networkDiagramsApi.uploadThumbnail(savedId, dataUrl)
}
} catch {
// Thumbnail failure is silent — doesn't affect save success
}
}
} catch {
toast.error('Failed to save diagram')
} finally {
setIsSaving(false)
}
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes])
useEffect(() => {
const interval = setInterval(() => {
@@ -254,13 +397,21 @@ function DiagramEditorInner() {
}, [handleSave])
const onConnect = useCallback((connection: Connection) => {
pushHistory(nodes, edges)
setEdges(eds => addEdge({
...connection,
type: 'connection',
data: { connectionType: 'ethernet' },
}, eds))
setIsDirty(true)
}, [setEdges])
}, [nodes, edges, pushHistory, setEdges])
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
pushHistory(nodes, edges)
setEdges(eds => reconnectEdge(oldEdge, newConnection, eds))
setSelectedEdgeId(oldEdge.id)
setIsDirty(true)
}, [nodes, edges, pushHistory, setEdges])
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault()
@@ -292,11 +443,22 @@ function DiagramEditorInner() {
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
event.preventDefault()
setContextMenu({
type: 'canvas',
position: { x: event.clientX, y: event.clientY },
})
}, [])
// Group nodes pass pointer events through to children, so right-clicking a group
// may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected,
// show the node context menu so group/align/ungroup options are accessible.
const selected = getNodes().filter(n => n.selected)
if (selected.length > 0) {
setContextMenu({
type: 'node',
position: { x: event.clientX, y: event.clientY },
})
} else {
setContextMenu({
type: 'canvas',
position: { x: event.clientX, y: event.clientY },
})
}
}, [getNodes])
const closeContextMenu = useCallback(() => {
setContextMenu(null)
@@ -334,6 +496,7 @@ function DiagramEditorInner() {
} satisfies DeviceProperties,
} satisfies DeviceNodeData,
}
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode])
setIsDirty(true)
return
@@ -353,20 +516,23 @@ function DiagramEditorInner() {
groupType: slug,
},
}
pushHistory(nodes, edges)
setNodes(nds => [...nds, newNode])
setIsDirty(true)
}
}, [setNodes, screenToFlowPosition])
}, [nodes, edges, pushHistory, setNodes, screenToFlowPosition])
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
pushHistory(nodes, edges)
setNodes(nds => nds.map(n => {
if (n.id !== nodeId) return n
return { ...n, data: { ...n.data, ...updates } }
}))
setIsDirty(true)
}, [setNodes])
}, [nodes, edges, pushHistory, setNodes])
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
pushHistory(nodes, edges)
setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e
return {
@@ -382,44 +548,50 @@ function DiagramEditorInner() {
}
}))
setIsDirty(true)
}, [setEdges])
}, [nodes, edges, pushHistory, setEdges])
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
pushHistory(nodes, edges)
setEdges(eds => eds.map(e => {
if (e.id !== edgeId) return e
return { ...e, type: edgeType }
}))
setIsDirty(true)
}, [setEdges])
}, [nodes, edges, pushHistory, setEdges])
const handleDeleteNode = useCallback((nodeId: string) => {
pushHistory(nodes, edges)
setNodes(nds => nds.filter(n => n.id !== nodeId))
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
setSelectedNodeId(null)
setIsDirty(true)
}, [setNodes, setEdges])
}, [nodes, edges, pushHistory, setNodes, setEdges])
const handleDeleteEdge = useCallback((edgeId: string) => {
pushHistory(nodes, edges)
setEdges(eds => eds.filter(e => e.id !== edgeId))
setSelectedEdgeId(null)
setIsDirty(true)
}, [setEdges])
}, [nodes, edges, pushHistory, setEdges])
const handleBringToFront = useCallback((nodeId: string) => {
setNodes(nds => {
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
pushHistory(nodes, edges)
setNodes(prev => {
const maxZ = Math.max(0, ...prev.map(n => n.zIndex ?? 0))
return normalizeZOrder(
prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
)
})
setIsDirty(true)
}, [setNodes])
}, [nodes, edges, pushHistory, setNodes])
const handleSendToBack = useCallback((nodeId: string) => {
setNodes(nds => {
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n)
})
pushHistory(nodes, edges)
setNodes(prev => normalizeZOrder(
prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n)
))
setIsDirty(true)
}, [setNodes])
}, [nodes, edges, pushHistory, setNodes])
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
const newNodes: Node[] = result.nodes.map(n => ({
@@ -442,6 +614,7 @@ function DiagramEditorInner() {
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
}))
pushHistory(nodes, edges)
if (mode === 'replace') {
setNodes(newNodes)
setEdges(newEdges)
@@ -463,7 +636,7 @@ function DiagramEditorInner() {
setIsDirty(true)
setTimeout(() => fitView({ padding: 0.2 }), 100)
}, [setNodes, setEdges, diagramId, fitView])
}, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView])
const getExistingBounds = useCallback(() => {
const currentNodes = getNodes()
@@ -514,6 +687,42 @@ function DiagramEditorInner() {
}
}, [nodes, name])
const handleExportSvg = useCallback(async () => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
try {
const { toSvg } = await import('html-to-image')
const IMAGE_WIDTH = 1920
const IMAGE_HEIGHT = 1080
const bounds = getNodesBounds(nodes)
const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15)
const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null
if (!flowEl) {
toast.error('Could not find canvas to export')
return
}
const dataUrl = await toSvg(flowEl, {
backgroundColor: '#16181f',
width: IMAGE_WIDTH,
height: IMAGE_HEIGHT,
style: {
width: `${IMAGE_WIDTH}px`,
height: `${IMAGE_HEIGHT}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
transformOrigin: 'top left',
},
})
const a = document.createElement('a')
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.svg`
a.href = dataUrl
a.click()
} catch {
toast.error('SVG export failed')
}
}, [nodes, name])
const handleExportPdf = useCallback(() => {
window.print()
}, [])
@@ -534,6 +743,54 @@ function DiagramEditorInner() {
}
}, [diagramId, name])
const handleExportDrawio = useCallback(() => {
if (nodes.length === 0) {
toast.warning('Add some devices to the diagram before exporting')
return
}
const xml = exportToDrawio(getNodes(), edges)
const blob = new Blob([xml], { type: 'application/xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.drawio`
a.click()
URL.revokeObjectURL(url)
}, [nodes, edges, getNodes, name])
const handleImportDrawio = useCallback(() => {
drawioImportRef.current?.click()
}, [])
const handleDrawioFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const text = await file.text()
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
const importPayload = {
schemaVersion: 1 as const,
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
client_name: null,
description: null,
nodes: importedNodes,
edges: importedEdges,
}
const result = await networkDiagramsApi.importJson(importPayload)
const allWarnings = [...warnings, ...result.warnings]
if (allWarnings.length > 0) {
toast.warning(`Imported with ${allWarnings.length} warning(s): ${allWarnings[0]}`)
} else {
toast.success('draw.io file imported successfully')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
toast.error(`Import failed: ${msg}`)
}
}, [navigate])
if (loading) {
return (
<div className="flex h-full items-center justify-center">
@@ -551,11 +808,20 @@ function DiagramEditorInner() {
isSaving={isSaving}
lastSavedAt={lastSavedAt}
diagramId={diagramId}
onNameChange={n => { setName(n); setIsDirty(true) }}
onNameChange={(n: string) => { setName(n); setIsDirty(true) }}
onSave={handleSave}
onExportPng={handleExportPng}
onExportSvg={handleExportSvg}
onExportPdf={handleExportPdf}
onExportJson={handleExportJson}
onExportDrawio={handleExportDrawio}
onImportDrawio={handleImportDrawio}
onUndo={undo}
onRedo={redo}
canUndo={canUndo}
canRedo={canRedo}
interactionMode={interactionMode}
onModeChange={setInteractionMode}
/>
<div className="flex flex-1 min-h-0">
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
@@ -567,6 +833,7 @@ function DiagramEditorInner() {
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
onNodeSelect={setSelectedNodeId}
onEdgeSelect={setSelectedEdgeId}
onDrop={onDrop}
@@ -576,10 +843,24 @@ function DiagramEditorInner() {
onNodeContextMenu={handleNodeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
onPaneClick={closeContextMenu}
interactionMode={interactionMode}
/>
{interactionMode === 'connect' && (
<div className="pointer-events-none absolute left-1/2 top-4 z-10 -translate-x-1/2 rounded-full border border-accent/30 bg-card/95 px-3 py-1.5 text-[11px] text-muted-foreground">
Connect mode: drag between device handles. Middle-click and drag to pan.
</div>
)}
{nodes.length === 0 && !loading && (
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
)}
{/* Keyboard shortcut hint button — above the MiniMap */}
<button
onClick={() => setShowShortcuts(true)}
title="Keyboard shortcuts (?)"
className="absolute bottom-[175px] right-3 z-10 flex h-6 w-6 items-center justify-center rounded-full border border-default bg-card text-[11px] font-semibold text-muted-foreground hover:border-accent hover:text-accent transition-colors"
>
?
</button>
</div>
{nodes.length > 0 && (
<AIAssistPanel
@@ -599,6 +880,21 @@ function DiagramEditorInner() {
onSendToBack={handleSendToBack}
onDeleteNode={handleDeleteNode}
onDeleteEdge={handleDeleteEdge}
selectedNodeCount={nodes.filter(n => n.selected).length}
onAlignLeft={diagramCommands.alignLeft}
onAlignRight={diagramCommands.alignRight}
onAlignCenterH={diagramCommands.alignCenterH}
onAlignTop={diagramCommands.alignTop}
onAlignBottom={diagramCommands.alignBottom}
onAlignCenterV={diagramCommands.alignCenterV}
onDistributeH={diagramCommands.distributeHorizontally}
onDistributeV={diagramCommands.distributeVertically}
canAlign={diagramCommands.canAlign}
canDistribute={diagramCommands.canDistribute}
canGroup={diagramCommands.canGroup}
canUngroup={diagramCommands.canUngroup}
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
/>
</div>
{contextMenu && (
@@ -626,6 +922,20 @@ function DiagramEditorInner() {
})
}
onClose={closeContextMenu}
onAlignLeft={diagramCommands.alignLeft}
onAlignRight={diagramCommands.alignRight}
onAlignCenterH={diagramCommands.alignCenterH}
onAlignTop={diagramCommands.alignTop}
onAlignBottom={diagramCommands.alignBottom}
onAlignCenterV={diagramCommands.alignCenterV}
onDistributeH={diagramCommands.distributeHorizontally}
onDistributeV={diagramCommands.distributeVertically}
canAlign={contextMenu.type === 'node' ? diagramCommands.canAlign : false}
canDistribute={contextMenu.type === 'node' ? diagramCommands.canDistribute : false}
onGroupSelection={diagramCommands.groupSelection}
onUngroupSelection={diagramCommands.ungroupSelection}
canGroup={contextMenu?.type === 'node' ? diagramCommands.canGroup : false}
canUngroup={contextMenu?.type === 'node' ? diagramCommands.canUngroup : false}
/>
)}
{pendingDeleteNodeId && (
@@ -647,6 +957,16 @@ function DiagramEditorInner() {
</div>
</div>
)}
<input
ref={drawioImportRef}
type="file"
accept=".drawio,.xml"
className="hidden"
onChange={handleDrawioFileChange}
/>
{showShortcuts && (
<KeyboardShortcutsOverlay onClose={() => setShowShortcuts(false)} />
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react'
import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput, ExternalLink, Copy, Archive } from 'lucide-react'
import { cn } from '@/lib/utils'
import { networkDiagramsApi } from '@/api'
import { toast } from '@/lib/toast'
@@ -39,7 +39,10 @@ export default function NetworkDiagramsPage() {
const [clientSearch, setClientSearch] = useState('')
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
const [importMenuOpen, setImportMenuOpen] = useState(false)
const clientDropdownRef = useRef<HTMLDivElement>(null)
const importMenuRef = useRef<HTMLDivElement>(null)
const drawioListImportRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!clientDropdownOpen) return
@@ -52,6 +55,17 @@ export default function NetworkDiagramsPage() {
return () => document.removeEventListener('mousedown', handleClick)
}, [clientDropdownOpen])
useEffect(() => {
if (!importMenuOpen) return
const handleClick = (e: MouseEvent) => {
if (importMenuRef.current && !importMenuRef.current.contains(e.target as Node)) {
setImportMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [importMenuOpen])
const loadDiagrams = useCallback(async () => {
try {
const params: Record<string, string> = {}
@@ -129,6 +143,35 @@ export default function NetworkDiagramsPage() {
input.click()
}, [navigate])
const handleListDrawioImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const { parseDrawioXml } = await import('@/lib/drawio-import')
const text = await file.text()
const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text)
const result = await networkDiagramsApi.importJson({
schemaVersion: 1,
name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram',
client_name: null,
description: null,
nodes: importedNodes,
edges: importedEdges,
})
const allWarnings = [...warnings, ...result.warnings]
if (allWarnings.length > 0) {
toast.warning(`Imported with ${allWarnings.length} warning(s)`)
} else {
toast.success('Imported successfully')
}
navigate(`/network-diagrams/${result.diagram.id}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'
toast.error(`Import failed: ${msg}`)
}
}, [navigate])
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
@@ -141,13 +184,48 @@ export default function NetworkDiagramsPage() {
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
</div>
<div className="flex gap-2">
<button
onClick={handleImport}
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import
</button>
{/* Single "Import" dropdown replacing two separate buttons */}
<div className="relative" ref={importMenuRef}>
<button
onClick={() => setImportMenuOpen(prev => !prev)}
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import
<ChevronDown size={12} className="text-muted-foreground" />
</button>
{importMenuOpen && (
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded border border-default bg-card py-1 shadow-lg">
<button
onClick={() => { setImportMenuOpen(false); handleImport() }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
>
<FileJson size={13} />
<div className="text-left">
<div>Import JSON</div>
<div className="text-[10px] text-muted-foreground">ResolutionFlow format</div>
</div>
</button>
<button
onClick={() => { setImportMenuOpen(false); drawioListImportRef.current?.click() }}
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-elevated"
>
<FileOutput size={13} />
<div className="text-left">
<div>Import draw.io</div>
<div className="text-[10px] text-muted-foreground">.drawio or .xml file</div>
</div>
</button>
</div>
)}
</div>
<input
ref={drawioListImportRef}
type="file"
accept=".drawio,.xml"
className="hidden"
onChange={handleListDrawioImport}
/>
<button
onClick={() => navigate('/network-diagrams/new')}
className="flex items-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
@@ -222,16 +300,111 @@ export default function NetworkDiagramsPage() {
)}
{!loading && diagrams.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<Network size={48} className="mb-4 text-muted-foreground" />
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
<button
onClick={() => navigate('/network-diagrams/new')}
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
Create First Diagram
</button>
<div className="rounded-lg border border-default bg-card overflow-hidden">
<div className="grid md:grid-cols-[1fr_380px]">
{/* Left: mini topology preview */}
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
{/* Dot grid background */}
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="1" fill="#4f5666" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#dots)" />
</svg>
{/* Static topology SVG */}
<svg viewBox="0 0 460 240" className="relative w-full max-w-md" xmlns="http://www.w3.org/2000/svg">
{/* Edges */}
<line x1="230" y1="48" x2="130" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
<line x1="230" y1="48" x2="230" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
<line x1="230" y1="48" x2="330" y2="130" stroke="#2a2e3a" strokeWidth="1.5" />
<line x1="130" y1="130" x2="80" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="130" y1="130" x2="180" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="330" y1="130" x2="280" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
<line x1="330" y1="130" x2="380" y2="210" stroke="#2a2e3a" strokeWidth="1.5" strokeDasharray="4 3" />
{/* Firewall node */}
<rect x="196" y="16" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
<rect x="207" y="22" width="14" height="10" rx="2" fill="#f87171" opacity="0.9" />
<rect x="225" y="22" width="6" height="10" rx="1" fill="#3d4252" />
<rect x="235" y="22" width="20" height="10" rx="2" fill="#3d4252" />
<text x="230" y="46" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Firewall</text>
{/* Switch node */}
<rect x="96" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
<circle cx="112" cy="124" r="4" fill="#34d399" opacity="0.9" />
<circle cx="122" cy="124" r="4" fill="#34d399" opacity="0.9" />
<circle cx="132" cy="124" r="4" fill="#fbbf24" opacity="0.8" />
<circle cx="142" cy="124" r="4" fill="#34d399" opacity="0.9" />
<text x="130" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Core Switch</text>
{/* Router node */}
<rect x="196" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#60a5fa" strokeWidth="1" />
<circle cx="230" cy="124" r="10" fill="none" stroke="#60a5fa" strokeWidth="1.5" opacity="0.7" />
<circle cx="230" cy="124" r="5" fill="none" stroke="#60a5fa" strokeWidth="1" opacity="0.5" />
<line x1="220" y1="124" x2="240" y2="124" stroke="#60a5fa" strokeWidth="1" opacity="0.6" />
<text x="230" y="140" textAnchor="middle" fill="#93c5fd" fontSize="9" fontFamily="monospace">Router</text>
{/* Server farm */}
<rect x="296" y="108" width="68" height="40" rx="6" fill="#1e2028" stroke="#3d4252" strokeWidth="1" />
<rect x="308" y="116" width="44" height="7" rx="2" fill="#2a2e3a" />
<rect x="308" y="127" width="44" height="7" rx="2" fill="#2a2e3a" />
<circle cx="345" cy="119.5" r="2" fill="#34d399" opacity="0.9" />
<circle cx="345" cy="130.5" r="2" fill="#34d399" opacity="0.9" />
<text x="330" y="140" textAnchor="middle" fill="#848b9b" fontSize="9" fontFamily="monospace">Servers</text>
{/* Leaf nodes */}
<rect x="46" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="72" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">PC × 12</text>
<rect x="154" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="180" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">AP × 4</text>
<rect x="254" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="280" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">NAS</text>
<rect x="354" y="190" width="52" height="34" rx="5" fill="#1e2028" stroke="#2a2e3a" strokeWidth="1" />
<text x="380" y="211" textAnchor="middle" fill="#4f5666" fontSize="8" fontFamily="monospace">VM × 6</text>
</svg>
</div>
{/* Right: value prop + CTA */}
<div className="flex flex-col justify-center border-l border-default p-8">
<div className="mb-1 flex items-center gap-2">
<Network size={14} className="text-accent" />
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">Network Maps</span>
</div>
<h2 className="font-heading text-xl font-bold text-heading leading-snug">
Document every client's infrastructure — once
</h2>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">
Drag-and-drop topology diagrams that live next to your tickets. Generate a first draft from a plain-text description, then keep it up to date as networks change.
</p>
<ul className="mt-4 space-y-2 text-xs text-muted-foreground">
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
AI topology generation from natural language
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
Export to PNG, SVG, PDF, or draw.io
</li>
<li className="flex items-center gap-2">
<div className="h-1.5 w-1.5 rounded-full bg-accent" />
Shared across your whole team instantly
</li>
</ul>
<div className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
<button
onClick={() => navigate('/network-diagrams/new')}
className="flex items-center justify-center gap-1.5 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
>
<Plus size={14} />
Create Network Map
</button>
<button
onClick={() => { setImportMenuOpen(true) }}
className="flex items-center justify-center gap-1.5 rounded border border-default px-4 py-2 text-sm text-primary hover:border-hover"
>
<Upload size={14} />
Import existing
</button>
</div>
</div>
</div>
</div>
)}
@@ -260,6 +433,29 @@ export default function NetworkDiagramsPage() {
{d.description && (
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
)}
{/* Thumbnail preview */}
{d.thumbnail_url ? (
<div className="mb-2 overflow-hidden rounded border border-default">
<img
src={d.thumbnail_url}
alt={d.name}
className="h-[120px] w-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
) : (
<div className="relative mb-2 flex h-[120px] items-center justify-center overflow-hidden rounded border border-default bg-[#0e1016]">
<svg className="absolute inset-0 h-full w-full opacity-30" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id={`dots-${d.id}`} x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.8" fill="#4f5666" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#dots-${d.id})`} />
</svg>
<Network size={24} className="relative text-muted-foreground/20" />
</div>
)}
{d.node_count > 0 && (
<div className="mb-2">
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
@@ -294,20 +490,24 @@ export default function NetworkDiagramsPage() {
<>
<button
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
<ExternalLink size={12} className="text-muted-foreground" />
Open
</button>
<button
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-primary hover:bg-elevated"
>
<Copy size={12} className="text-muted-foreground" />
Duplicate
</button>
<div className="my-1 border-t border-default" />
<button
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
className="w-full px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-red-400 hover:bg-elevated"
>
<Archive size={12} />
Archive
</button>
</>

View File

@@ -18,6 +18,7 @@ export interface DiagramNode {
properties: DeviceProperties
nodeType?: string
style?: { width?: number; height?: number } | null
parentId?: string | null
}
export interface DiagramEdge {
@@ -28,7 +29,7 @@ export interface DiagramEdge {
connectionType: string
speed: string | null
notes: string | null
routing?: string | null
routing?: 'curved' | 'step' | 'orthogonal' | null
}
export interface DeviceTypeResponse {
@@ -72,6 +73,7 @@ export interface NetworkDiagramListItem {
description: string | null
node_count: number
category_counts: Record<string, number>
thumbnail_url?: string | null
created_by: string | null
created_at: string
updated_at: string
@@ -123,6 +125,12 @@ export interface DiagramImportResponse {
warnings: string[]
}
export interface GroupNodeData {
label: string
groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom'
[key: string]: unknown
}
export interface DiagramExportResponse {
schemaVersion: number
name: string