feat: network diagrams — draw.io-style editor #139
@@ -1,10 +1,12 @@
|
|||||||
"""Network diagrams API endpoints."""
|
"""Network diagrams API endpoints."""
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ from app.schemas.network_diagram import (
|
|||||||
DiagramNode,
|
DiagramNode,
|
||||||
DiagramEdge,
|
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
|
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
|
||||||
_SLUG_CATEGORY: dict[str, str] = {
|
_SLUG_CATEGORY: dict[str, str] = {
|
||||||
@@ -83,6 +85,7 @@ def _diagram_to_list_item(
|
|||||||
description=diagram.description,
|
description=diagram.description,
|
||||||
node_count=len(nodes),
|
node_count=len(nodes),
|
||||||
category_counts=category_counts,
|
category_counts=category_counts,
|
||||||
|
thumbnail_url=diagram.thumbnail_url,
|
||||||
created_by=diagram.created_by,
|
created_by=diagram.created_by,
|
||||||
created_at=diagram.created_at,
|
created_at=diagram.created_at,
|
||||||
updated_at=diagram.updated_at,
|
updated_at=diagram.updated_at,
|
||||||
@@ -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)
|
@router.post("/ai-generate", response_model=AIGenerateResponse)
|
||||||
async def ai_generate_diagram(
|
async def ai_generate_diagram(
|
||||||
data: AIGenerateRequest,
|
data: AIGenerateRequest,
|
||||||
|
|||||||
@@ -22,12 +22,20 @@ class DeviceProperties(BaseModel):
|
|||||||
status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$")
|
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):
|
class DiagramNode(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
label: str
|
label: str
|
||||||
position: Position
|
position: Position
|
||||||
properties: DeviceProperties = Field(default_factory=DeviceProperties)
|
properties: DeviceProperties = Field(default_factory=DeviceProperties)
|
||||||
|
nodeType: str | None = None
|
||||||
|
style: NodeStyle | None = None
|
||||||
|
parentId: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class DiagramEdge(BaseModel):
|
class DiagramEdge(BaseModel):
|
||||||
@@ -84,6 +92,7 @@ class NetworkDiagramListItem(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
node_count: int = 0
|
node_count: int = 0
|
||||||
category_counts: dict[str, int] = Field(default_factory=dict)
|
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
thumbnail_url: str | None = None
|
||||||
created_by: UUID | None = None
|
created_by: UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -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.
|
||||||
1320
docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md
Normal file
1320
docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,10 @@ export const networkDiagramsApi = {
|
|||||||
return response.data
|
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> {
|
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
|
||||||
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
|
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
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'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface MenuAction {
|
interface MenuAction {
|
||||||
@@ -15,9 +20,41 @@ interface ContextMenuProps {
|
|||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
actions: MenuAction[]
|
actions: MenuAction[]
|
||||||
onClose: () => void
|
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 menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const clampedPosition = { ...position }
|
const clampedPosition = { ...position }
|
||||||
@@ -83,6 +120,59 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 {
|
interface DiagramHeaderProps {
|
||||||
name: string
|
name: string
|
||||||
@@ -12,8 +15,17 @@ interface DiagramHeaderProps {
|
|||||||
onNameChange: (name: string) => void
|
onNameChange: (name: string) => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onExportPng: () => void
|
onExportPng: () => void
|
||||||
|
onExportSvg: () => void
|
||||||
onExportPdf: () => void
|
onExportPdf: () => void
|
||||||
onExportJson: () => 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({
|
export function DiagramHeader({
|
||||||
@@ -26,8 +38,17 @@ export function DiagramHeader({
|
|||||||
onNameChange,
|
onNameChange,
|
||||||
onSave,
|
onSave,
|
||||||
onExportPng,
|
onExportPng,
|
||||||
|
onExportSvg,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
onExportJson,
|
onExportJson,
|
||||||
|
onExportDrawio,
|
||||||
|
onImportDrawio,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
interactionMode,
|
||||||
|
onModeChange,
|
||||||
}: DiagramHeaderProps) {
|
}: DiagramHeaderProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [editing, setEditing] = useState(false)
|
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="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 ? (
|
{editing ? (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
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"
|
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} />
|
<Download size={14} />
|
||||||
Export
|
Export / Import
|
||||||
</button>
|
</button>
|
||||||
{showExportMenu && (
|
{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
|
<button
|
||||||
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
onClick={() => { onExportPng(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
onClick={() => { onExportPdf(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
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>
|
</button>
|
||||||
{diagramId && (
|
{diagramId && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
onClick={() => { onExportJson(); setShowExportMenu(false) }}
|
||||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-primary hover:bg-elevated"
|
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>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal file
129
frontend/src/components/network/KeyboardShortcutsOverlay.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MiniMap,
|
MiniMap,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
type OnConnect,
|
type OnConnect,
|
||||||
|
type OnReconnect,
|
||||||
type OnNodesChange,
|
type OnNodesChange,
|
||||||
type OnEdgesChange,
|
type OnEdgesChange,
|
||||||
type Node,
|
type Node,
|
||||||
@@ -15,6 +16,8 @@ import { nodeTypes } from './nodes/nodeTypes'
|
|||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
import { getDeviceRenderConfig } from './nodes/deviceRegistry'
|
||||||
import type { DeviceNodeData } from './nodes/DeviceNode'
|
import type { DeviceNodeData } from './nodes/DeviceNode'
|
||||||
|
import type { InteractionMode } from './DiagramHeader'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface NetworkCanvasProps {
|
interface NetworkCanvasProps {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
@@ -22,6 +25,7 @@ interface NetworkCanvasProps {
|
|||||||
onNodesChange: OnNodesChange
|
onNodesChange: OnNodesChange
|
||||||
onEdgesChange: OnEdgesChange
|
onEdgesChange: OnEdgesChange
|
||||||
onConnect: OnConnect
|
onConnect: OnConnect
|
||||||
|
onReconnect: OnReconnect<Edge>
|
||||||
onNodeSelect: (nodeId: string | null) => void
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
onEdgeSelect: (edgeId: string | null) => void
|
onEdgeSelect: (edgeId: string | null) => void
|
||||||
onDrop: (event: React.DragEvent) => void
|
onDrop: (event: React.DragEvent) => void
|
||||||
@@ -31,6 +35,7 @@ interface NetworkCanvasProps {
|
|||||||
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void
|
||||||
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void
|
||||||
onPaneClick?: () => void
|
onPaneClick?: () => void
|
||||||
|
interactionMode?: InteractionMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkCanvas({
|
export function NetworkCanvas({
|
||||||
@@ -39,6 +44,7 @@ export function NetworkCanvas({
|
|||||||
onNodesChange,
|
onNodesChange,
|
||||||
onEdgesChange,
|
onEdgesChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onReconnect,
|
||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onEdgeSelect,
|
onEdgeSelect,
|
||||||
onDrop,
|
onDrop,
|
||||||
@@ -48,6 +54,7 @@ export function NetworkCanvas({
|
|||||||
onNodeContextMenu,
|
onNodeContextMenu,
|
||||||
onPaneContextMenu,
|
onPaneContextMenu,
|
||||||
onPaneClick: onPaneClickProp,
|
onPaneClick: onPaneClickProp,
|
||||||
|
interactionMode = 'select',
|
||||||
}: NetworkCanvasProps) {
|
}: NetworkCanvasProps) {
|
||||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
|
||||||
if (selectedNodes.length === 1) {
|
if (selectedNodes.length === 1) {
|
||||||
@@ -75,13 +82,22 @@ export function NetworkCanvas({
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
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
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onReconnect={onReconnect}
|
||||||
onSelectionChange={handleSelectionChange}
|
onSelectionChange={handleSelectionChange}
|
||||||
onPaneClick={handlePaneClick}
|
onPaneClick={handlePaneClick}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
@@ -91,12 +107,23 @@ export function NetworkCanvas({
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
defaultEdgeOptions={{ type: 'connection' }}
|
defaultEdgeOptions={{ type: 'connection' }}
|
||||||
|
edgesReconnectable
|
||||||
|
connectOnClick={interactionMode === 'connect'}
|
||||||
|
reconnectRadius={20}
|
||||||
|
connectionRadius={24}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
multiSelectionKeyCode="Shift"
|
multiSelectionKeyCode="Shift"
|
||||||
|
panOnDrag={interactionMode === 'pan' ? [0, 1] : [1]}
|
||||||
|
selectionOnDrag={interactionMode === 'select'}
|
||||||
|
panActivationKeyCode="Space"
|
||||||
snapToGrid={true}
|
snapToGrid={true}
|
||||||
snapGrid={[20, 20]}
|
snapGrid={[20, 20]}
|
||||||
fitView
|
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} />
|
<Background variant={BackgroundVariant.Dots} color="var(--color-border-default)" gap={20} size={1} />
|
||||||
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
<Controls className="!border-default !bg-card [&>button]:!border-default [&>button]:!bg-card [&>button]:!fill-text-primary" />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function getEdgePath(routing: string | null | undefined, props: EdgeProps) {
|
|||||||
}
|
}
|
||||||
if (routing === 'curved') return getBezierPath(base)
|
if (routing === 'curved') return getBezierPath(base)
|
||||||
if (routing === 'step') return getSmoothStepPath(base)
|
if (routing === 'step') return getSmoothStepPath(base)
|
||||||
|
if (routing === 'orthogonal') return getSmoothStepPath({ ...base, borderRadius: 0 })
|
||||||
return getStraightPath(base)
|
return getStraightPath(base)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ function ConnectionEdgeComponent(props: EdgeProps) {
|
|||||||
{props.label && (
|
{props.label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<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={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export function useCanvasShortcuts({
|
|||||||
setEdges,
|
setEdges,
|
||||||
setIsDirty,
|
setIsDirty,
|
||||||
canvasRef,
|
canvasRef,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onNudge,
|
||||||
|
onSetMode,
|
||||||
|
onToggleShortcuts,
|
||||||
}: {
|
}: {
|
||||||
nodes: Node[]
|
nodes: Node[]
|
||||||
edges: Edge[]
|
edges: Edge[]
|
||||||
@@ -40,6 +45,11 @@ export function useCanvasShortcuts({
|
|||||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>
|
||||||
setIsDirty: (dirty: boolean) => void
|
setIsDirty: (dirty: boolean) => void
|
||||||
canvasRef: React.RefObject<HTMLDivElement | null>
|
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 { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow()
|
||||||
const clipboardRef = useRef<ClipboardData | null>(null)
|
const clipboardRef = useRef<ClipboardData | null>(null)
|
||||||
@@ -211,6 +221,45 @@ export function useCanvasShortcuts({
|
|||||||
|
|
||||||
const ctrl = e.ctrlKey || e.metaKey
|
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') {
|
if (ctrl && e.key === 'c') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
copyNodes()
|
copyNodes()
|
||||||
@@ -232,12 +281,15 @@ export function useCanvasShortcuts({
|
|||||||
} else if (e.key === '[' && !ctrl) {
|
} else if (e.key === '[' && !ctrl) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
sendSelectedToBack()
|
sendSelectedToBack()
|
||||||
|
} else if (e.key === '?' && !ctrl) {
|
||||||
|
e.preventDefault()
|
||||||
|
onToggleShortcuts()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('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 {
|
return {
|
||||||
copyNodes,
|
copyNodes,
|
||||||
|
|||||||
206
frontend/src/components/network/hooks/useDiagramCommands.ts
Normal file
206
frontend/src/components/network/hooks/useDiagramCommands.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { memo } from 'react'
|
import { memo, useState, useRef, useEffect } from 'react'
|
||||||
import { Position, NodeResizer, type NodeProps } from '@xyflow/react'
|
import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react'
|
||||||
import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node'
|
import { BaseNode, BaseNodeHeader, BaseNodeContent } from '../ui/base-node'
|
||||||
import { BaseHandle } from '../ui/base-handle'
|
import { BaseHandle } from '../ui/base-handle'
|
||||||
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
|
import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator'
|
||||||
import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip'
|
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'
|
import type { DeviceProperties } from '@/types'
|
||||||
|
|
||||||
export interface DeviceNodeData {
|
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_MIN = 80 // minimum square side in px
|
||||||
const NODE_MAX = 280 // maximum 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 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 status = (nodeData.properties?.status || 'unknown') as NodeStatus
|
||||||
const ip = nodeData.properties?.ip
|
const ip = nodeData.properties?.ip
|
||||||
const props = nodeData.properties || {}
|
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)))
|
const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11)))
|
||||||
// IP font: 9px at default, clamped to [8, 16]
|
// IP font: 9px at default, clamped to [8, 16]
|
||||||
const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9)))
|
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
|
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}>
|
<NodeStatusIndicator status={status}>
|
||||||
<NodeTooltip>
|
<NodeTooltip>
|
||||||
<NodeTooltipTrigger>
|
<NodeTooltipTrigger>
|
||||||
<BaseNode className="w-full h-full group flex flex-col items-center justify-center">
|
<BaseNode className="group h-full w-full bg-card">
|
||||||
<BaseNodeHeader className="flex-col gap-1 items-center py-2 px-2">
|
<div className={cn('pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b opacity-80', surfaceClass)} />
|
||||||
<Icon size={iconPx} style={{ color }} />
|
<BaseNodeHeader className="flex h-full flex-col items-center justify-center gap-2 px-2 py-3">
|
||||||
<BaseNodeHeaderTitle className="text-center leading-tight" style={{ fontSize: labelPx }}>
|
<div
|
||||||
{nodeData.label}
|
className={cn(
|
||||||
</BaseNodeHeaderTitle>
|
'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>
|
</BaseNodeHeader>
|
||||||
{ip && (
|
{ip && (
|
||||||
<BaseNodeContent className="items-center pt-0 pb-1">
|
<BaseNodeContent className="items-center pt-0 pb-2">
|
||||||
<span className="font-mono text-muted-foreground" style={{ fontSize: ipPx }}>{ip}</span>
|
<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>
|
</BaseNodeContent>
|
||||||
)}
|
)}
|
||||||
<BaseHandle type="target" position={Position.Top} />
|
<BaseHandle type="target" position={Position.Top} />
|
||||||
|
|||||||
86
frontend/src/components/network/nodes/GroupNode.tsx
Normal file
86
frontend/src/components/network/nodes/GroupNode.tsx
Normal 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
|
||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
export interface DeviceRenderConfig {
|
export interface DeviceRenderConfig {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
color: string
|
color: string
|
||||||
|
accentClass: string
|
||||||
|
surfaceClass: string
|
||||||
|
category: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category-semantic color palette — each color carries meaning:
|
// Category-semantic color palette — each color carries meaning:
|
||||||
@@ -27,62 +30,107 @@ export const STORAGE_COLOR = '#a78bfa'
|
|||||||
export const CLOUD_COLOR = '#67e8f9'
|
export const CLOUD_COLOR = '#67e8f9'
|
||||||
export const INFRA_COLOR = '#94a3b8'
|
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> = {
|
const SYSTEM_DEVICE_ICONS: Record<string, DeviceRenderConfig> = {
|
||||||
// Network layer
|
// Network layer
|
||||||
'router': { icon: Router, color: NETWORK_COLOR },
|
'router': makeConfig(Router, NETWORK_COLOR, 'network'),
|
||||||
'switch': { icon: Network, color: NETWORK_COLOR },
|
'switch': makeConfig(Network, NETWORK_COLOR, 'network'),
|
||||||
'access-point': { icon: Wifi, color: NETWORK_COLOR },
|
'access-point': makeConfig(Wifi, NETWORK_COLOR, 'network'),
|
||||||
'load-balancer': { icon: Gauge, color: NETWORK_COLOR },
|
'load-balancer': makeConfig(Gauge, NETWORK_COLOR, 'network'),
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
'firewall': { icon: BrickWallFire, color: SECURITY_COLOR },
|
'firewall': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'),
|
||||||
'badge-reader': { icon: KeyRound, color: SECURITY_COLOR },
|
'badge-reader': makeConfig(KeyRound, SECURITY_COLOR, 'security'),
|
||||||
|
|
||||||
// Compute
|
// Compute
|
||||||
'server': { icon: Server, color: COMPUTE_COLOR },
|
'server': makeConfig(Server, COMPUTE_COLOR, 'compute'),
|
||||||
'vm': { icon: Boxes, color: COMPUTE_COLOR },
|
'vm': makeConfig(Boxes, COMPUTE_COLOR, 'compute'),
|
||||||
'container': { icon: Package, color: COMPUTE_COLOR },
|
'container': makeConfig(Package, COMPUTE_COLOR, 'compute'),
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
'nas': { icon: Database, color: STORAGE_COLOR },
|
'nas': makeConfig(Database, STORAGE_COLOR, 'storage'),
|
||||||
'san': { icon: HardDrive, color: STORAGE_COLOR },
|
'san': makeConfig(HardDrive, STORAGE_COLOR, 'storage'),
|
||||||
'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR },
|
'cloud-storage': makeConfig(CloudCog, STORAGE_COLOR, 'storage'),
|
||||||
|
|
||||||
// Cloud / Internet
|
// Cloud / Internet
|
||||||
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||||
'aws': { icon: Cloud, color: CLOUD_COLOR },
|
'aws': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||||
'azure': { icon: Cloud, color: CLOUD_COLOR },
|
'azure': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||||
'gcp': { icon: Cloud, color: CLOUD_COLOR },
|
'gcp': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||||
'isp': { icon: Globe, color: CLOUD_COLOR },
|
'isp': makeConfig(Globe, CLOUD_COLOR, 'cloud'),
|
||||||
|
|
||||||
// Endpoints
|
// Endpoints
|
||||||
'workstation': { icon: Monitor, color: ENDPOINT_COLOR },
|
'workstation': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'),
|
||||||
'laptop': { icon: Laptop, color: ENDPOINT_COLOR },
|
'laptop': makeConfig(Laptop, ENDPOINT_COLOR, 'endpoint'),
|
||||||
'tablet': { icon: Tablet, color: ENDPOINT_COLOR },
|
'tablet': makeConfig(Tablet, ENDPOINT_COLOR, 'endpoint'),
|
||||||
'phone': { icon: Smartphone, color: ENDPOINT_COLOR },
|
'phone': makeConfig(Smartphone, ENDPOINT_COLOR, 'endpoint'),
|
||||||
'printer': { icon: Printer, color: ENDPOINT_COLOR },
|
'printer': makeConfig(Printer, ENDPOINT_COLOR, 'endpoint'),
|
||||||
|
|
||||||
// Infrastructure / physical
|
// Infrastructure / physical
|
||||||
'ups': { icon: BatteryCharging, color: INFRA_COLOR },
|
'ups': makeConfig(BatteryCharging, INFRA_COLOR, 'infrastructure'),
|
||||||
'pdu': { icon: PlugZap, color: INFRA_COLOR },
|
'pdu': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'),
|
||||||
'rack': { icon: RectangleVertical, color: INFRA_COLOR },
|
'rack': makeConfig(RectangleVertical, INFRA_COLOR, 'infrastructure'),
|
||||||
'patch-panel': { icon: Cable, color: INFRA_COLOR },
|
'patch-panel': makeConfig(Cable, INFRA_COLOR, 'infrastructure'),
|
||||||
'camera': { icon: Camera, color: INFRA_COLOR },
|
'camera': makeConfig(Camera, INFRA_COLOR, 'infrastructure'),
|
||||||
'nvr': { icon: Video, color: INFRA_COLOR },
|
'nvr': makeConfig(Video, INFRA_COLOR, 'infrastructure'),
|
||||||
'iot': { icon: Radio, color: INFRA_COLOR },
|
'iot': makeConfig(Radio, INFRA_COLOR, 'infrastructure'),
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
const CATEGORY_DEFAULTS: Record<string, DeviceRenderConfig> = {
|
||||||
'network': { icon: Router, color: NETWORK_COLOR },
|
'network': makeConfig(Router, NETWORK_COLOR, 'network'),
|
||||||
'compute': { icon: Server, color: COMPUTE_COLOR },
|
'compute': makeConfig(Server, COMPUTE_COLOR, 'compute'),
|
||||||
'storage': { icon: Database, color: STORAGE_COLOR },
|
'storage': makeConfig(Database, STORAGE_COLOR, 'storage'),
|
||||||
'cloud': { icon: Cloud, color: CLOUD_COLOR },
|
'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'),
|
||||||
'endpoint': { icon: Monitor, color: ENDPOINT_COLOR },
|
'endpoint': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'),
|
||||||
'infrastructure': { icon: PlugZap, color: INFRA_COLOR },
|
'infrastructure': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'),
|
||||||
'security': { icon: BrickWallFire, color: SECURITY_COLOR },
|
'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 {
|
export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig {
|
||||||
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
|
if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DeviceNode } from './DeviceNode'
|
import { DeviceNode } from './DeviceNode'
|
||||||
import { GroupNode } from '../ui/labeled-group-node'
|
import { GroupNode } from './GroupNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
device: DeviceNode,
|
device: DeviceNode,
|
||||||
|
|||||||
@@ -151,22 +151,22 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
{[
|
{[
|
||||||
{ slug: 'subnet', label: 'Subnet' },
|
{ slug: 'subnet', label: 'Subnet', color: '#60a5fa' },
|
||||||
{ slug: 'vlan', label: 'VLAN' },
|
{ slug: 'vlan', label: 'VLAN', color: '#a78bfa' },
|
||||||
{ slug: 'site', label: 'Site' },
|
{ slug: 'site', label: 'Site', color: '#34d399' },
|
||||||
{ slug: 'dmz', label: 'DMZ' },
|
{ slug: 'dmz', label: 'DMZ', color: '#f87171' },
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div
|
<div
|
||||||
key={item.slug}
|
key={item.slug}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => {
|
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'
|
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"
|
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" />
|
<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>
|
<span>{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react'
|
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 { cn } from '@/lib/utils'
|
||||||
import type { DeviceProperties, DiagramEdge } from '@/types'
|
import type { DeviceProperties, DiagramEdge } from '@/types'
|
||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
@@ -15,6 +21,21 @@ interface PropertiesPanelProps {
|
|||||||
onSendToBack: (nodeId: string) => void
|
onSendToBack: (nodeId: string) => void
|
||||||
onDeleteNode: (nodeId: string) => void
|
onDeleteNode: (nodeId: string) => void
|
||||||
onDeleteEdge: (edgeId: 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'
|
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({
|
export function PropertiesPanel({
|
||||||
selectedNode,
|
selectedNode,
|
||||||
selectedEdge,
|
selectedEdge,
|
||||||
@@ -78,8 +107,24 @@ export function PropertiesPanel({
|
|||||||
onSendToBack,
|
onSendToBack,
|
||||||
onDeleteNode,
|
onDeleteNode,
|
||||||
onDeleteEdge,
|
onDeleteEdge,
|
||||||
|
selectedNodeCount,
|
||||||
|
onAlignLeft,
|
||||||
|
onAlignRight,
|
||||||
|
onAlignCenterH,
|
||||||
|
onAlignTop,
|
||||||
|
onAlignBottom,
|
||||||
|
onAlignCenterV,
|
||||||
|
onDistributeH,
|
||||||
|
onDistributeV,
|
||||||
|
canAlign,
|
||||||
|
canDistribute,
|
||||||
|
canGroup,
|
||||||
|
canUngroup,
|
||||||
|
onGroupSelection,
|
||||||
|
onUngroupSelection,
|
||||||
}: PropertiesPanelProps) {
|
}: PropertiesPanelProps) {
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
const [deleteConfirm, setDeleteConfirm] = useState(false)
|
||||||
|
const [pendingGroupType, setPendingGroupType] = useState('subnet')
|
||||||
|
|
||||||
// Reset confirm state whenever the selection changes
|
// Reset confirm state whenever the selection changes
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// 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>)
|
onNodeUpdate(selectedNode.id, { label: value } as Partial<DeviceNodeData>)
|
||||||
}, [selectedNode, onNodeUpdate])
|
}, [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) {
|
if (!selectedNode && !selectedEdge) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-4">
|
<div className="flex h-full w-[260px] flex-col items-center justify-center border-l border-default bg-sidebar px-6">
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg border border-default bg-elevated text-muted-foreground">
|
||||||
Select a device or connection to edit its properties
|
<MousePointer size={15} />
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-xs font-medium text-muted-foreground">
|
||||||
|
Select a device or connection
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-center text-[10px] text-muted-foreground/60">
|
<p className="mt-1 text-center text-[10px] text-muted-foreground/50 leading-relaxed">
|
||||||
Hover a device to preview its info
|
Properties appear here. Hover a device to see a quick summary.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -122,6 +260,9 @@ export function PropertiesPanel({
|
|||||||
<h3 className="text-xs font-semibold text-heading">Connection</h3>
|
<h3 className="text-xs font-semibold text-heading">Connection</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
<div className="flex flex-1 flex-col gap-3 overflow-y-auto p-3">
|
||||||
|
<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">
|
<div className="flex flex-col gap-1">
|
||||||
<FieldLabel>Label</FieldLabel>
|
<FieldLabel>Label</FieldLabel>
|
||||||
<FieldInput
|
<FieldInput
|
||||||
@@ -179,6 +320,7 @@ export function PropertiesPanel({
|
|||||||
{ value: null, icon: Minus, label: 'Straight' },
|
{ value: null, icon: Minus, label: 'Straight' },
|
||||||
{ value: 'curved', icon: Spline, label: 'Curved' },
|
{ value: 'curved', icon: Spline, label: 'Curved' },
|
||||||
{ value: 'step', icon: GitBranch, label: 'Step' },
|
{ value: 'step', icon: GitBranch, label: 'Step' },
|
||||||
|
{ value: 'orthogonal', icon: CornerUpRight, label: 'Ortho' },
|
||||||
] as const).map(({ value, icon: Icon, label }) => {
|
] as const).map(({ value, icon: Icon, label }) => {
|
||||||
const routing = (edgeData.routing as string | null | undefined) ?? null
|
const routing = (edgeData.routing as string | null | undefined) ?? null
|
||||||
const active = routing === value
|
const active = routing === value
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ export function BaseHandle({ className, children, ...props }: ComponentProps<typ
|
|||||||
<Handle
|
<Handle
|
||||||
{...props}
|
{...props}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[10px] w-[10px] rounded-full border border-default bg-elevated transition-opacity',
|
'h-3 w-3 rounded-full border border-accent/60 bg-card transition-opacity',
|
||||||
'opacity-0 group-hover:opacity-100',
|
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
|
||||||
|
'[.rf-connect-mode_&]:opacity-100',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export function BaseNode({ className, ...props }: ComponentProps<'div'>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-card text-heading relative rounded-lg border border-default',
|
'bg-card text-heading relative overflow-hidden rounded-xl border border-default',
|
||||||
'transition-colors hover:border-hover',
|
'transition-colors hover:border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||||
'in-[.selected]:border-accent',
|
'in-[.selected]:border-accent',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const STATUS_BORDER_COLORS: Record<NodeStatus, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_GLOW: Record<NodeStatus, string> = {
|
const STATUS_GLOW: Record<NodeStatus, string> = {
|
||||||
online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]',
|
online: 'shadow-[0_0_6px_rgba(52,211,153,0.15)]',
|
||||||
offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]',
|
offline: 'shadow-[0_0_6px_rgba(248,113,113,0.15)]',
|
||||||
degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]',
|
degraded: 'shadow-[0_0_6px_rgba(250,204,21,0.15)]',
|
||||||
unknown: '',
|
unknown: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export function NodeStatusIndicator({ status = 'unknown', children, className }:
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border-2 transition-colors',
|
'w-full h-full rounded-lg border-2 transition-colors',
|
||||||
STATUS_BORDER_COLORS[status],
|
STATUS_BORDER_COLORS[status],
|
||||||
STATUS_GLOW[status],
|
STATUS_GLOW[status],
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -14,20 +14,21 @@ const NodeTooltipContext = createContext<NodeTooltipContextValue>({
|
|||||||
hide: () => {},
|
hide: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) {
|
export function NodeTooltip({ children, className, ...props }: ComponentProps<'div'>) {
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const show = useCallback(() => setVisible(true), [])
|
const show = useCallback(() => setVisible(true), [])
|
||||||
const hide = useCallback(() => setVisible(false), [])
|
const hide = useCallback(() => setVisible(false), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
|
<NodeTooltipContext.Provider value={{ visible, show, hide }}>
|
||||||
<div {...props}>{children}</div>
|
<div className={cn('w-full h-full', className)} {...props}>{children}</div>
|
||||||
</NodeTooltipContext.Provider>
|
</NodeTooltipContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeTooltipTrigger({
|
export function NodeTooltipTrigger({
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
...props
|
...props
|
||||||
@@ -36,6 +37,7 @@ export function NodeTooltipTrigger({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className={cn('w-full h-full', className)}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
show()
|
show()
|
||||||
onMouseEnter?.(e)
|
onMouseEnter?.(e)
|
||||||
|
|||||||
@@ -445,3 +445,42 @@
|
|||||||
scroll-behavior: auto !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
99
frontend/src/lib/drawio-export.ts
Normal file
99
frontend/src/lib/drawio-export.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
142
frontend/src/lib/drawio-import.ts
Normal file
142
frontend/src/lib/drawio-import.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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 { useParams, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
useNodesState,
|
useNodesState,
|
||||||
useEdgesState,
|
useEdgesState,
|
||||||
addEdge,
|
addEdge,
|
||||||
|
reconnectEdge,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
getNodesBounds,
|
getNodesBounds,
|
||||||
getViewportForBounds,
|
getViewportForBounds,
|
||||||
@@ -17,16 +18,25 @@ import '@xyflow/react/dist/style.css'
|
|||||||
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
import { NetworkCanvas } from '@/components/network/NetworkCanvas'
|
||||||
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu'
|
||||||
import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts'
|
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 { DeviceToolbar } from '@/components/network/panels/DeviceToolbar'
|
||||||
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel'
|
||||||
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel'
|
||||||
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt'
|
||||||
|
import { KeyboardShortcutsOverlay } from '@/components/network/KeyboardShortcutsOverlay'
|
||||||
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
import { networkDiagramsApi, deviceTypesApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
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 { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types'
|
||||||
import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode'
|
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 ContextMenuState = {
|
||||||
type: 'node' | 'canvas'
|
type: 'node' | 'canvas'
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
@@ -63,13 +73,78 @@ function DiagramEditorInner() {
|
|||||||
const [loading, setLoading] = useState(!!id)
|
const [loading, setLoading] = useState(!!id)
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
|
|
||||||
|
const [interactionMode, setInteractionMode] = useState<InteractionMode>('select')
|
||||||
|
const [showShortcuts, setShowShortcuts] = useState(false)
|
||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null)
|
const canvasRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const drawioImportRef = useRef<HTMLInputElement>(null)
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null)
|
||||||
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
useEffect(() => { isDirtyRef.current = isDirty }, [isDirty])
|
||||||
useEffect(() => { diagramIdRef.current = diagramId }, [diagramId])
|
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 {
|
const {
|
||||||
copyNodes,
|
copyNodes,
|
||||||
pasteNodes,
|
pasteNodes,
|
||||||
@@ -84,6 +159,11 @@ function DiagramEditorInner() {
|
|||||||
setEdges,
|
setEdges,
|
||||||
setIsDirty: (v: boolean) => setIsDirty(v),
|
setIsDirty: (v: boolean) => setIsDirty(v),
|
||||||
canvasRef,
|
canvasRef,
|
||||||
|
onUndo: undo,
|
||||||
|
onRedo: redo,
|
||||||
|
onNudge,
|
||||||
|
onSetMode: setInteractionMode,
|
||||||
|
onToggleShortcuts: () => setShowShortcuts(v => !v),
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
const handleNodesChange: typeof onNodesChange = useCallback((changes) => {
|
||||||
@@ -137,6 +217,7 @@ function DiagramEditorInner() {
|
|||||||
type: 'device',
|
type: 'device',
|
||||||
position: n.position,
|
position: n.position,
|
||||||
style: n.style || { width: 120, height: 120 },
|
style: n.style || { width: 120, height: 120 },
|
||||||
|
...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}),
|
||||||
data: {
|
data: {
|
||||||
label: n.label,
|
label: n.label,
|
||||||
deviceType: n.type,
|
deviceType: n.type,
|
||||||
@@ -161,6 +242,37 @@ function DiagramEditorInner() {
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
setLastSavedAt(new Date(diagram.updated_at))
|
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 {
|
} catch {
|
||||||
toast.error('Failed to load diagram')
|
toast.error('Failed to load diagram')
|
||||||
navigate('/network-diagrams')
|
navigate('/network-diagrams')
|
||||||
@@ -169,7 +281,7 @@ function DiagramEditorInner() {
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [id, navigate, setNodes, setEdges])
|
}, [id, navigate, setNodes, setEdges, pushHistory])
|
||||||
|
|
||||||
const serializeNodes = useCallback((): DiagramNode[] => {
|
const serializeNodes = useCallback((): DiagramNode[] => {
|
||||||
return getNodes().map(n => {
|
return getNodes().map(n => {
|
||||||
@@ -197,6 +309,7 @@ function DiagramEditorInner() {
|
|||||||
position: n.position,
|
position: n.position,
|
||||||
properties: data.properties,
|
properties: data.properties,
|
||||||
style: { width: dw, height: dh },
|
style: { width: dw, height: dh },
|
||||||
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [getNodes])
|
}, [getNodes])
|
||||||
@@ -212,7 +325,7 @@ function DiagramEditorInner() {
|
|||||||
connectionType: d.connectionType as string || 'ethernet',
|
connectionType: d.connectionType as string || 'ethernet',
|
||||||
speed: d.speed as string || null,
|
speed: d.speed as string || null,
|
||||||
notes: d.notes as string || null,
|
notes: d.notes as string || null,
|
||||||
routing: d.routing as string || null,
|
routing: (d.routing as DiagramEdge['routing']) || null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [edges])
|
}, [edges])
|
||||||
@@ -228,21 +341,51 @@ function DiagramEditorInner() {
|
|||||||
nodes: serializeNodes(),
|
nodes: serializeNodes(),
|
||||||
edges: serializeEdges(),
|
edges: serializeEdges(),
|
||||||
}
|
}
|
||||||
|
let savedId: string | null = diagramIdRef.current
|
||||||
if (diagramIdRef.current) {
|
if (diagramIdRef.current) {
|
||||||
await networkDiagramsApi.update(diagramIdRef.current, payload)
|
await networkDiagramsApi.update(diagramIdRef.current, payload)
|
||||||
} else {
|
} else {
|
||||||
const created = await networkDiagramsApi.create(payload)
|
const created = await networkDiagramsApi.create(payload)
|
||||||
|
savedId = created.id
|
||||||
setDiagramId(created.id)
|
setDiagramId(created.id)
|
||||||
navigate(`/network-diagrams/${created.id}`, { replace: true })
|
navigate(`/network-diagrams/${created.id}`, { replace: true })
|
||||||
}
|
}
|
||||||
setIsDirty(false)
|
setIsDirty(false)
|
||||||
setLastSavedAt(new Date())
|
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 {
|
} catch {
|
||||||
toast.error('Failed to save diagram')
|
toast.error('Failed to save diagram')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
|
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -254,13 +397,21 @@ function DiagramEditorInner() {
|
|||||||
}, [handleSave])
|
}, [handleSave])
|
||||||
|
|
||||||
const onConnect = useCallback((connection: Connection) => {
|
const onConnect = useCallback((connection: Connection) => {
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setEdges(eds => addEdge({
|
setEdges(eds => addEdge({
|
||||||
...connection,
|
...connection,
|
||||||
type: 'connection',
|
type: 'connection',
|
||||||
data: { connectionType: 'ethernet' },
|
data: { connectionType: 'ethernet' },
|
||||||
}, eds))
|
}, eds))
|
||||||
setIsDirty(true)
|
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) => {
|
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -292,11 +443,22 @@ function DiagramEditorInner() {
|
|||||||
|
|
||||||
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setContextMenu({
|
// Group nodes pass pointer events through to children, so right-clicking a group
|
||||||
type: 'canvas',
|
// may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected,
|
||||||
position: { x: event.clientX, y: event.clientY },
|
// 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(() => {
|
const closeContextMenu = useCallback(() => {
|
||||||
setContextMenu(null)
|
setContextMenu(null)
|
||||||
@@ -334,6 +496,7 @@ function DiagramEditorInner() {
|
|||||||
} satisfies DeviceProperties,
|
} satisfies DeviceProperties,
|
||||||
} satisfies DeviceNodeData,
|
} satisfies DeviceNodeData,
|
||||||
}
|
}
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setNodes(nds => [...nds, newNode])
|
setNodes(nds => [...nds, newNode])
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
return
|
return
|
||||||
@@ -353,20 +516,23 @@ function DiagramEditorInner() {
|
|||||||
groupType: slug,
|
groupType: slug,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setNodes(nds => [...nds, newNode])
|
setNodes(nds => [...nds, newNode])
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}
|
}
|
||||||
}, [setNodes, screenToFlowPosition])
|
}, [nodes, edges, pushHistory, setNodes, screenToFlowPosition])
|
||||||
|
|
||||||
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
|
const handleNodeUpdate = useCallback((nodeId: string, updates: Partial<DeviceNodeData>) => {
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setNodes(nds => nds.map(n => {
|
setNodes(nds => nds.map(n => {
|
||||||
if (n.id !== nodeId) return n
|
if (n.id !== nodeId) return n
|
||||||
return { ...n, data: { ...n.data, ...updates } }
|
return { ...n, data: { ...n.data, ...updates } }
|
||||||
}))
|
}))
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setNodes])
|
}, [nodes, edges, pushHistory, setNodes])
|
||||||
|
|
||||||
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
|
const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial<DiagramEdge>) => {
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setEdges(eds => eds.map(e => {
|
setEdges(eds => eds.map(e => {
|
||||||
if (e.id !== edgeId) return e
|
if (e.id !== edgeId) return e
|
||||||
return {
|
return {
|
||||||
@@ -382,44 +548,50 @@ function DiagramEditorInner() {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setEdges])
|
}, [nodes, edges, pushHistory, setEdges])
|
||||||
|
|
||||||
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => {
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setEdges(eds => eds.map(e => {
|
setEdges(eds => eds.map(e => {
|
||||||
if (e.id !== edgeId) return e
|
if (e.id !== edgeId) return e
|
||||||
return { ...e, type: edgeType }
|
return { ...e, type: edgeType }
|
||||||
}))
|
}))
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setEdges])
|
}, [nodes, edges, pushHistory, setEdges])
|
||||||
|
|
||||||
const handleDeleteNode = useCallback((nodeId: string) => {
|
const handleDeleteNode = useCallback((nodeId: string) => {
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setNodes(nds => nds.filter(n => n.id !== nodeId))
|
setNodes(nds => nds.filter(n => n.id !== nodeId))
|
||||||
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
|
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId))
|
||||||
setSelectedNodeId(null)
|
setSelectedNodeId(null)
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setNodes, setEdges])
|
}, [nodes, edges, pushHistory, setNodes, setEdges])
|
||||||
|
|
||||||
const handleDeleteEdge = useCallback((edgeId: string) => {
|
const handleDeleteEdge = useCallback((edgeId: string) => {
|
||||||
|
pushHistory(nodes, edges)
|
||||||
setEdges(eds => eds.filter(e => e.id !== edgeId))
|
setEdges(eds => eds.filter(e => e.id !== edgeId))
|
||||||
setSelectedEdgeId(null)
|
setSelectedEdgeId(null)
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setEdges])
|
}, [nodes, edges, pushHistory, setEdges])
|
||||||
|
|
||||||
const handleBringToFront = useCallback((nodeId: string) => {
|
const handleBringToFront = useCallback((nodeId: string) => {
|
||||||
setNodes(nds => {
|
pushHistory(nodes, edges)
|
||||||
const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0))
|
setNodes(prev => {
|
||||||
return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n)
|
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)
|
setIsDirty(true)
|
||||||
}, [setNodes])
|
}, [nodes, edges, pushHistory, setNodes])
|
||||||
|
|
||||||
const handleSendToBack = useCallback((nodeId: string) => {
|
const handleSendToBack = useCallback((nodeId: string) => {
|
||||||
setNodes(nds => {
|
pushHistory(nodes, edges)
|
||||||
const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0))
|
setNodes(prev => normalizeZOrder(
|
||||||
return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n)
|
prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n)
|
||||||
})
|
))
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
}, [setNodes])
|
}, [nodes, edges, pushHistory, setNodes])
|
||||||
|
|
||||||
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
|
const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => {
|
||||||
const newNodes: Node[] = result.nodes.map(n => ({
|
const newNodes: Node[] = result.nodes.map(n => ({
|
||||||
@@ -442,6 +614,7 @@ function DiagramEditorInner() {
|
|||||||
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
|
data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
pushHistory(nodes, edges)
|
||||||
if (mode === 'replace') {
|
if (mode === 'replace') {
|
||||||
setNodes(newNodes)
|
setNodes(newNodes)
|
||||||
setEdges(newEdges)
|
setEdges(newEdges)
|
||||||
@@ -463,7 +636,7 @@ function DiagramEditorInner() {
|
|||||||
|
|
||||||
setIsDirty(true)
|
setIsDirty(true)
|
||||||
setTimeout(() => fitView({ padding: 0.2 }), 100)
|
setTimeout(() => fitView({ padding: 0.2 }), 100)
|
||||||
}, [setNodes, setEdges, diagramId, fitView])
|
}, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView])
|
||||||
|
|
||||||
const getExistingBounds = useCallback(() => {
|
const getExistingBounds = useCallback(() => {
|
||||||
const currentNodes = getNodes()
|
const currentNodes = getNodes()
|
||||||
@@ -514,6 +687,42 @@ function DiagramEditorInner() {
|
|||||||
}
|
}
|
||||||
}, [nodes, name])
|
}, [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(() => {
|
const handleExportPdf = useCallback(() => {
|
||||||
window.print()
|
window.print()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -534,6 +743,54 @@ function DiagramEditorInner() {
|
|||||||
}
|
}
|
||||||
}, [diagramId, name])
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
@@ -551,11 +808,20 @@ function DiagramEditorInner() {
|
|||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
lastSavedAt={lastSavedAt}
|
lastSavedAt={lastSavedAt}
|
||||||
diagramId={diagramId}
|
diagramId={diagramId}
|
||||||
onNameChange={n => { setName(n); setIsDirty(true) }}
|
onNameChange={(n: string) => { setName(n); setIsDirty(true) }}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExportPng={handleExportPng}
|
onExportPng={handleExportPng}
|
||||||
|
onExportSvg={handleExportSvg}
|
||||||
onExportPdf={handleExportPdf}
|
onExportPdf={handleExportPdf}
|
||||||
onExportJson={handleExportJson}
|
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">
|
<div className="flex flex-1 min-h-0">
|
||||||
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
<DeviceToolbar deviceTypes={deviceTypes} onDeviceTypesChange={loadDeviceTypes} />
|
||||||
@@ -567,6 +833,7 @@ function DiagramEditorInner() {
|
|||||||
onNodesChange={handleNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={handleEdgesChange}
|
onEdgesChange={handleEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onReconnect={onReconnect}
|
||||||
onNodeSelect={setSelectedNodeId}
|
onNodeSelect={setSelectedNodeId}
|
||||||
onEdgeSelect={setSelectedEdgeId}
|
onEdgeSelect={setSelectedEdgeId}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
@@ -576,10 +843,24 @@ function DiagramEditorInner() {
|
|||||||
onNodeContextMenu={handleNodeContextMenu}
|
onNodeContextMenu={handleNodeContextMenu}
|
||||||
onPaneContextMenu={handlePaneContextMenu}
|
onPaneContextMenu={handlePaneContextMenu}
|
||||||
onPaneClick={closeContextMenu}
|
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 && (
|
{nodes.length === 0 && !loading && (
|
||||||
<CanvasEmptyPrompt onGenerate={handleAIGenerate} />
|
<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>
|
</div>
|
||||||
{nodes.length > 0 && (
|
{nodes.length > 0 && (
|
||||||
<AIAssistPanel
|
<AIAssistPanel
|
||||||
@@ -599,6 +880,21 @@ function DiagramEditorInner() {
|
|||||||
onSendToBack={handleSendToBack}
|
onSendToBack={handleSendToBack}
|
||||||
onDeleteNode={handleDeleteNode}
|
onDeleteNode={handleDeleteNode}
|
||||||
onDeleteEdge={handleDeleteEdge}
|
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>
|
</div>
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
@@ -626,6 +922,20 @@ function DiagramEditorInner() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
onClose={closeContextMenu}
|
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 && (
|
{pendingDeleteNodeId && (
|
||||||
@@ -647,6 +957,16 @@ function DiagramEditorInner() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<input
|
||||||
|
ref={drawioImportRef}
|
||||||
|
type="file"
|
||||||
|
accept=".drawio,.xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleDrawioFileChange}
|
||||||
|
/>
|
||||||
|
{showShortcuts && (
|
||||||
|
<KeyboardShortcutsOverlay onClose={() => setShowShortcuts(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Search, Network, MoreHorizontal, Upload, 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 { cn } from '@/lib/utils'
|
||||||
import { networkDiagramsApi } from '@/api'
|
import { networkDiagramsApi } from '@/api'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -39,7 +39,10 @@ export default function NetworkDiagramsPage() {
|
|||||||
const [clientSearch, setClientSearch] = useState('')
|
const [clientSearch, setClientSearch] = useState('')
|
||||||
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
const [menuOpenId, setMenuOpenId] = useState<string | null>(null)
|
||||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null)
|
||||||
|
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||||||
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
const clientDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const importMenuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const drawioListImportRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!clientDropdownOpen) return
|
if (!clientDropdownOpen) return
|
||||||
@@ -52,6 +55,17 @@ export default function NetworkDiagramsPage() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick)
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
}, [clientDropdownOpen])
|
}, [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 () => {
|
const loadDiagrams = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {}
|
const params: Record<string, string> = {}
|
||||||
@@ -129,6 +143,35 @@ export default function NetworkDiagramsPage() {
|
|||||||
input.click()
|
input.click()
|
||||||
}, [navigate])
|
}, [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) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
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>
|
<p className="mt-1 text-sm text-muted-foreground">Visual network topology documentation for your clients</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
{/* Single "Import" dropdown replacing two separate buttons */}
|
||||||
onClick={handleImport}
|
<div className="relative" ref={importMenuRef}>
|
||||||
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
<button
|
||||||
>
|
onClick={() => setImportMenuOpen(prev => !prev)}
|
||||||
<Upload size={14} />
|
className="flex items-center gap-1.5 rounded border border-default px-3 py-2 text-sm text-primary hover:border-hover"
|
||||||
Import
|
>
|
||||||
</button>
|
<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
|
<button
|
||||||
onClick={() => navigate('/network-diagrams/new')}
|
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"
|
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 && (
|
{!loading && diagrams.length === 0 && (
|
||||||
<div className="flex flex-col items-center justify-center py-20">
|
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||||||
<Network size={48} className="mb-4 text-muted-foreground" />
|
<div className="grid md:grid-cols-[1fr_380px]">
|
||||||
<h2 className="font-heading text-lg font-semibold text-heading">No network maps yet</h2>
|
{/* Left: mini topology preview */}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Create your first network diagram to get started</p>
|
<div className="relative flex items-center justify-center bg-[#0e1016] p-8 md:p-12 min-h-[280px]">
|
||||||
<button
|
{/* Dot grid background */}
|
||||||
onClick={() => navigate('/network-diagrams/new')}
|
<svg className="absolute inset-0 h-full w-full opacity-20" xmlns="http://www.w3.org/2000/svg">
|
||||||
className="mt-4 rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90"
|
<defs>
|
||||||
>
|
<pattern id="dots" x="0" y="0" width="24" height="24" patternUnits="userSpaceOnUse">
|
||||||
Create First Diagram
|
<circle cx="1" cy="1" r="1" fill="#4f5666" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -260,6 +433,29 @@ export default function NetworkDiagramsPage() {
|
|||||||
{d.description && (
|
{d.description && (
|
||||||
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
{d.node_count > 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
||||||
@@ -294,20 +490,24 @@ export default function NetworkDiagramsPage() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/network-diagrams/${d.id}`) }}
|
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
|
Open
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); handleDuplicate(d.id) }}
|
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
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
|
<div className="my-1 border-t border-default" />
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); setConfirmArchiveId(d.id) }}
|
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…
|
Archive…
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface DiagramNode {
|
|||||||
properties: DeviceProperties
|
properties: DeviceProperties
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
style?: { width?: number; height?: number } | null
|
style?: { width?: number; height?: number } | null
|
||||||
|
parentId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramEdge {
|
export interface DiagramEdge {
|
||||||
@@ -28,7 +29,7 @@ export interface DiagramEdge {
|
|||||||
connectionType: string
|
connectionType: string
|
||||||
speed: string | null
|
speed: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
routing?: string | null
|
routing?: 'curved' | 'step' | 'orthogonal' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceTypeResponse {
|
export interface DeviceTypeResponse {
|
||||||
@@ -72,6 +73,7 @@ export interface NetworkDiagramListItem {
|
|||||||
description: string | null
|
description: string | null
|
||||||
node_count: number
|
node_count: number
|
||||||
category_counts: Record<string, number>
|
category_counts: Record<string, number>
|
||||||
|
thumbnail_url?: string | null
|
||||||
created_by: string | null
|
created_by: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -123,6 +125,12 @@ export interface DiagramImportResponse {
|
|||||||
warnings: string[]
|
warnings: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupNodeData {
|
||||||
|
label: string
|
||||||
|
groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom'
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiagramExportResponse {
|
export interface DiagramExportResponse {
|
||||||
schemaVersion: number
|
schemaVersion: number
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user