diff --git a/backend/app/api/endpoints/network_diagrams.py b/backend/app/api/endpoints/network_diagrams.py index e00ecf7a..d2538aa1 100644 --- a/backend/app/api/endpoints/network_diagrams.py +++ b/backend/app/api/endpoints/network_diagrams.py @@ -1,10 +1,12 @@ """Network diagrams API endpoints.""" +import base64 import logging from datetime import datetime, timezone from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import select, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -27,7 +29,7 @@ from app.schemas.network_diagram import ( DiagramNode, DiagramEdge, ) -from app.services import network_diagram_ai_service +from app.services import network_diagram_ai_service, storage_service # Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts _SLUG_CATEGORY: dict[str, str] = { @@ -83,6 +85,7 @@ def _diagram_to_list_item( description=diagram.description, node_count=len(nodes), category_counts=category_counts, + thumbnail_url=diagram.thumbnail_url, created_by=diagram.created_by, created_at=diagram.created_at, updated_at=diagram.updated_at, @@ -305,6 +308,34 @@ async def import_diagram( ) +class ThumbnailUploadRequest(BaseModel): + data_url: str # base64 PNG data URL: "data:image/png;base64,..." + + +@router.post("/{diagram_id}/thumbnail", status_code=204) +async def upload_thumbnail( + diagram_id: UUID, + body: ThumbnailUploadRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> None: + diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db) + try: + header, encoded = body.data_url.split(",", 1) + except ValueError: + raise HTTPException(status_code=422, detail="Invalid data URL format") + image_bytes = base64.b64decode(encoded) + storage_key = await storage_service.upload_file( + file_data=image_bytes, + filename=f"thumbnail-{diagram_id}.png", + content_type="image/png", + account_id=str(current_user.account_id), + ) + presigned_url = storage_service.get_presigned_url(storage_key) + diagram.thumbnail_url = presigned_url + await db.commit() + + @router.post("/ai-generate", response_model=AIGenerateResponse) async def ai_generate_diagram( data: AIGenerateRequest, diff --git a/backend/app/schemas/network_diagram.py b/backend/app/schemas/network_diagram.py index e31d5283..55ca149b 100644 --- a/backend/app/schemas/network_diagram.py +++ b/backend/app/schemas/network_diagram.py @@ -22,12 +22,20 @@ class DeviceProperties(BaseModel): status: str = Field(default="unknown", pattern=r"^(unknown|online|offline|degraded)$") +class NodeStyle(BaseModel): + width: float | None = None + height: float | None = None + + class DiagramNode(BaseModel): id: str type: str label: str position: Position properties: DeviceProperties = Field(default_factory=DeviceProperties) + nodeType: str | None = None + style: NodeStyle | None = None + parentId: str | None = None class DiagramEdge(BaseModel): @@ -84,6 +92,7 @@ class NetworkDiagramListItem(BaseModel): description: str | None = None node_count: int = 0 category_counts: dict[str, int] = Field(default_factory=dict) + thumbnail_url: str | None = None created_by: UUID | None = None created_at: datetime updated_at: datetime diff --git a/docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md b/docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md new file mode 100644 index 00000000..9dd2f9a2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md @@ -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. diff --git a/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md b/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md new file mode 100644 index 00000000..3fab8da5 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md @@ -0,0 +1,1320 @@ +# Network Diagrams Phase 2 — Draw.io-Grade Editing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Elevate the network diagram editor from a basic canvas to a draw.io-grade editing experience by adding undo/redo, keyboard nudging, alignment/distribution commands, a group node component, improved edge routing, inline label editing, and a command layer that wires all of these consistently across keyboard, context menu, and toolbar. + +**Architecture:** All new commands are exposed through a single `useDiagramCommands` hook that is the single source of truth for every action — keyboard shortcuts, context menu items, and toolbar buttons all call the same functions. Undo/redo is implemented as a lightweight snapshot stack in `DiagramEditor.tsx` using `useRef` for the history array and two new state integers (`historyIndex`, stack depth cap of 50). No new state management library is introduced. + +**Tech Stack:** React 19, TypeScript, `@xyflow/react` (React Flow v12), Lucide React, Tailwind CSS v4, Zustand (not used for diagram state — stays in DiagramEditor local state). + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `frontend/src/components/network/hooks/useDiagramCommands.ts` | **Create** | Central command layer: align, distribute, group, ungroup, z-order, nudge, undo, redo | +| `frontend/src/components/network/hooks/useCanvasShortcuts.ts` | **Modify** | Wire undo/redo + nudge shortcuts; delegate all other shortcuts to useDiagramCommands | +| `frontend/src/components/network/nodes/GroupNode.tsx` | **Create** | Visual group/container node with resize and editable label | +| `frontend/src/components/network/nodes/nodeTypes.ts` | **Modify** | Register GroupNode | +| `frontend/src/components/network/ContextMenu.tsx` | **Modify** | Add align/distribute/group/ungroup menu sections | +| `frontend/src/components/network/DiagramHeader.tsx` | **Modify** | Add undo/redo buttons | +| `frontend/src/components/network/panels/PropertiesPanel.tsx` | **Modify** | Add alignment buttons for multi-select, group properties editor | +| `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` | **Modify** | Add history stack, expose pushHistory/undo/redo, wire useDiagramCommands, selection tracking for multi-select | +| `frontend/src/types/network-diagram.ts` | **Modify** | Add `zIndex` to DiagramNode, add `GroupNodeData` interface | + +--- + +## Task 1: Undo/Redo History Stack in DiagramEditor + +**Files:** +- Modify: `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` + +This task adds a snapshot-based undo/redo stack. Every mutation to `nodes` or `edges` must call `pushHistory` before applying the change. + +- [ ] **Step 1: Add history state at the top of DiagramEditor component** + +In `DiagramEditor.tsx`, after the existing state declarations, add: + +```tsx +// History +const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]) +const historyIndex = useRef(-1) +const MAX_HISTORY = 50 + +const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => { + // Truncate any redo history beyond current index + 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 + } +}, []) + +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) +}, [setNodes, setEdges]) + +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) +}, [setNodes, setEdges]) + +const canUndo = historyIndex.current > 0 +const canRedo = historyIndex.current < historyStack.current.length - 1 +``` + +- [ ] **Step 2: Push history before every mutation** + +Find every place in `DiagramEditor.tsx` that calls `setNodes(...)` or `setEdges(...)` in response to a user action (not load/init), and prepend `pushHistory(nodes, edges)` before it. The main locations are: + +- `onNodeUpdate` (node property changes) +- `onEdgeUpdate` (edge property changes) +- `onEdgeTypeChange` +- `onBringToFront` +- `onSendToBack` +- `onDeleteNode` +- `onDeleteEdge` +- `onConnect` +- `handleDrop` (adding new node from palette) +- `handleAIGenerate` (after AI result applied) +- `handleImport` + +Example pattern: +```tsx +const onDeleteNode = useCallback((nodeId: string) => { + pushHistory(nodes, edges) // ← add this line before every setNodes/setEdges + setNodes(prev => prev.filter(n => n.id !== nodeId)) + setEdges(prev => prev.filter(e => e.source !== nodeId && e.target !== nodeId)) + if (selectedNodeId === nodeId) setSelectedNodeId(null) +}, [nodes, edges, pushHistory, selectedNodeId]) +``` + +- [ ] **Step 3: Initialize history on diagram load** + +In the `useEffect` that loads the diagram data (after `setNodes` and `setEdges` are called with loaded data), reset and push the initial snapshot: + +```tsx +historyStack.current = [] +historyIndex.current = -1 +pushHistory(loadedNodes, loadedEdges) +``` + +- [ ] **Step 4: Pass undo/redo/canUndo/canRedo down to children** + +Add these to the props passed to `DiagramHeader` and later to `useDiagramCommands`: + +```tsx + +``` + +- [ ] **Step 5: Build and verify no TypeScript errors** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output (clean). + +- [ ] **Step 6: Commit** + +```bash +cd /home/coder/resolutionflow +git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "feat(network): add undo/redo snapshot history stack to DiagramEditor" +``` + +--- + +## Task 2: Undo/Redo Buttons in DiagramHeader + +**Files:** +- Modify: `frontend/src/components/network/DiagramHeader.tsx` + +- [ ] **Step 1: Add undo/redo props to DiagramHeader** + +Find the props interface in `DiagramHeader.tsx` and add: + +```tsx +onUndo: () => void +onRedo: () => void +canUndo: boolean +canRedo: boolean +``` + +- [ ] **Step 2: Add undo/redo buttons to the header UI** + +In the header, find the left/center section (near the back button or title area) and add undo/redo buttons. Import `Undo2, Redo2` from `lucide-react`: + +```tsx +import { Undo2, Redo2, /* existing imports */ } from 'lucide-react' +``` + +Add the buttons in the header bar, grouped together: + +```tsx +
+ + +
+``` + +- [ ] **Step 3: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/network/DiagramHeader.tsx +git commit -m "feat(network): add undo/redo buttons to DiagramHeader" +``` + +--- + +## Task 3: Undo/Redo + Nudge Keyboard Shortcuts + +**Files:** +- Modify: `frontend/src/components/network/hooks/useCanvasShortcuts.ts` + +- [ ] **Step 1: Add undo and redo to the keyboard handler** + +In `useCanvasShortcuts.ts`, the hook receives callbacks. Add `onUndo` and `onRedo` to the parameters: + +```tsx +interface UseCanvasShortcutsParams { + // ... existing params + onUndo: () => void + onRedo: () => void + onNudge: (dx: number, dy: number) => void +} +``` + +- [ ] **Step 2: Wire Ctrl+Z and Ctrl+Y in the keydown handler** + +Inside the existing `keydown` event listener (where `isInputFocused` is checked), add: + +```tsx +if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { + e.preventDefault() + onUndo() + return +} +if ((e.key === 'y' && (e.ctrlKey || e.metaKey)) || (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey)) { + e.preventDefault() + onRedo() + return +} +``` + +- [ ] **Step 3: Add arrow key nudging** + +Arrow keys move selected nodes by 1px (plain) or 10px (Shift held). These should fire even when no input is focused — but only if nodes are selected. Add inside the keydown handler, after the input-focus guard: + +```tsx +const NUDGE_SMALL = 1 +const NUDGE_LARGE = 10 + +if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + // Only nudge if we have selected nodes (not when input focused) + if (isInputFocused) return + e.preventDefault() + const delta = e.shiftKey ? NUDGE_LARGE : NUDGE_SMALL + 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 +} +``` + +- [ ] **Step 4: Implement onNudge in DiagramEditor** + +In `DiagramEditor.tsx`, create and pass the nudge callback: + +```tsx +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]) +``` + +Pass `onUndo={undo}`, `onRedo={redo}`, `onNudge={onNudge}` to `useCanvasShortcuts`. + +- [ ] **Step 5: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/network/hooks/useCanvasShortcuts.ts \ + frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "feat(network): add undo/redo shortcuts (Ctrl+Z/Y) and arrow key nudging" +``` + +--- + +## Task 4: useDiagramCommands — Alignment & Distribution Command Layer + +**Files:** +- Create: `frontend/src/components/network/hooks/useDiagramCommands.ts` + +This is the central command layer. All alignment and distribution logic lives here and is called by context menu, keyboard, and toolbar buttons. + +- [ ] **Step 1: Create the hook file with alignment commands** + +Create `frontend/src/components/network/hooks/useDiagramCommands.ts`: + +```tsx +import { useCallback } from 'react' +import { Node } from '@xyflow/react' + +interface UseDiagramCommandsParams { + nodes: Node[] + edges: any[] + pushHistory: (nodes: Node[], edges: any[]) => void + setNodes: React.Dispatch> +} + +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 = {} + 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 = {} + 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 + + return { + alignLeft, + alignRight, + alignCenterH, + alignTop, + alignBottom, + alignCenterV, + distributeHorizontally, + distributeVertically, + canAlign, + canDistribute, + selectedNodes, + } +} +``` + +- [ ] **Step 2: Wire useDiagramCommands into DiagramEditor** + +In `DiagramEditor.tsx`, import and instantiate the hook: + +```tsx +import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands' + +// Inside the component: +const diagramCommands = useDiagramCommands({ + nodes, + edges, + pushHistory, + setNodes, +}) +``` + +- [ ] **Step 3: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/network/hooks/useDiagramCommands.ts \ + frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "feat(network): add useDiagramCommands — alignment and distribution command layer" +``` + +--- + +## Task 5: Alignment Buttons in Context Menu + +**Files:** +- Modify: `frontend/src/components/network/ContextMenu.tsx` + +- [ ] **Step 1: Add alignment commands to ContextMenu props** + +Find the ContextMenu props interface and add: + +```tsx +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 +``` + +- [ ] **Step 2: Add align/distribute section to the node context menu** + +Inside the node context menu (the section that shows copy/duplicate/delete), add a new section when `canAlign` is true: + +```tsx +{canAlign && ( + <> +
+
Align
+ + + + + + + {canDistribute && ( + <> +
+
Distribute
+ + + + )} + +)} +{(canGroup || canUngroup) && ( + <> +
+ {canGroup && ( + + )} + {canUngroup && ( + + )} + +)} +``` + +Add these imports from `lucide-react`: +```tsx +import { + AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, + AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, + AlignHorizontalSpaceAround, AlignVerticalSpaceAround, + BoxSelect, Ungroup, + // ... existing imports +} from 'lucide-react' +``` + +- [ ] **Step 3: Pass diagramCommands props to ContextMenu in DiagramEditor** + +In `DiagramEditor.tsx`, wire the context menu: + +```tsx + +``` + +- [ ] **Step 4: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/network/ContextMenu.tsx \ + frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "feat(network): add align/distribute/group sections to context menu" +``` + +--- + +## Task 6: Alignment Buttons in PropertiesPanel (Multi-Select) + +**Files:** +- Modify: `frontend/src/components/network/panels/PropertiesPanel.tsx` + +When multiple nodes are selected (no single node to inspect), show an alignment toolbar instead of the node properties form. + +- [ ] **Step 1: Add multi-select props to PropertiesPanel** + +Add to the PropertiesPanel props interface: + +```tsx +selectedNodeCount: number +onAlignLeft: () => void +onAlignRight: () => void +onAlignCenterH: () => void +onAlignTop: () => void +onAlignBottom: () => void +onAlignCenterV: () => void +onDistributeH: () => void +onDistributeV: () => void +canAlign: boolean +canDistribute: boolean +``` + +- [ ] **Step 2: Add a multi-select view** + +At the top of the PropertiesPanel render, before the existing single-node/edge views, add: + +```tsx +if (!selectedNodeId && !selectedEdgeId && selectedNodeCount >= 2) { + return ( +
+
+
+ {selectedNodeCount} nodes selected +
+
+
+
+
Align
+
+ {[ + { 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 }, + ].map(({ label, icon: Icon, action }) => ( + + ))} +
+
+ {canDistribute && ( +
+
Distribute
+
+ + +
+
+ )} +
+
+ ) +} +``` + +Add the same Lucide imports as Task 5. + +- [ ] **Step 3: Track multi-select count in DiagramEditor** + +In `DiagramEditor.tsx`, compute selected node count and pass it to PropertiesPanel: + +```tsx +const selectedNodeCount = nodes.filter(n => n.selected).length + + +``` + +- [ ] **Step 4: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/network/panels/PropertiesPanel.tsx \ + frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "feat(network): add alignment toolbar to PropertiesPanel for multi-select" +``` + +--- + +## Task 7: GroupNode Component + +**Files:** +- Create: `frontend/src/components/network/nodes/GroupNode.tsx` +- Modify: `frontend/src/components/network/nodes/nodeTypes.ts` +- Modify: `frontend/src/types/network-diagram.ts` + +- [ ] **Step 1: Add GroupNodeData type** + +In `frontend/src/types/network-diagram.ts`, add: + +```ts +export interface GroupNodeData { + label: string + groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom' + [key: string]: unknown +} +``` + +- [ ] **Step 2: Create GroupNode.tsx** + +Create `frontend/src/components/network/nodes/GroupNode.tsx`: + +```tsx +import { memo, useState, useRef, useEffect } from 'react' +import { NodeProps, NodeResizer } from '@xyflow/react' +import { GroupNodeData } from '@/types/network-diagram' + +const GROUP_COLORS: Record = { + subnet: '#60a5fa', // blue + vlan: '#a78bfa', // violet + site: '#34d399', // green + dmz: '#f87171', // red + custom: '#94a3b8', // slate +} + +const GroupNode = memo(({ 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(null) + + useEffect(() => { + if (editing) inputRef.current?.focus() + }, [editing]) + + const handleLabelCommit = () => { + setEditing(false) + // Label changes propagate via React Flow's onNodesChange — data update + // handled by parent via onNodeUpdate + } + + return ( + <> + +
+ {/* Label at top-left */} +
+ {editing ? ( + setLabelValue(e.target.value)} + onBlur={handleLabelCommit} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === 'Escape') handleLabelCommit() + e.stopPropagation() + }} + className="text-[11px] font-medium bg-transparent border-none outline-none text-primary min-w-[40px] max-w-[200px]" + style={{ color }} + /> + ) : ( + setEditing(true)} + > + {labelValue || groupData.groupType} + + )} +
+
+ + ) +}) + +GroupNode.displayName = 'GroupNode' +export default GroupNode +``` + +- [ ] **Step 3: Register GroupNode in nodeTypes.ts** + +Open `frontend/src/components/network/nodes/nodeTypes.ts` and add: + +```tsx +import GroupNode from './GroupNode' + +export const nodeTypes = { + device: DeviceNode, + group: GroupNode, // ← add this +} +``` + +- [ ] **Step 4: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/network/nodes/GroupNode.tsx \ + frontend/src/components/network/nodes/nodeTypes.ts \ + frontend/src/types/network-diagram.ts +git commit -m "feat(network): add GroupNode component with resize, inline label, and group type colors" +``` + +--- + +## Task 8: Group/Ungroup Commands + +**Files:** +- Modify: `frontend/src/components/network/hooks/useDiagramCommands.ts` + +- [ ] **Step 1: Add groupSelection and ungroupSelection to useDiagramCommands** + +Append to the hook (before the return statement): + +```tsx +const groupSelection = useCallback(() => { + if (selectedNodes.length < 2) return + pushHistory(nodes, edges) + + // Compute bounding box of selected nodes with padding + 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: 'Group', groupType: 'custom' }, + selected: false, + } + + // Re-position selected nodes relative to group origin + 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(() => { + // Find selected group nodes + 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 = Object.fromEntries( + prev.filter(n => groupIds.has(n.id)).map(n => [n.id, n.position]) + ) + return prev + .filter(n => !groupIds.has(n.id)) + .map(n => { + if (n.parentId && groupIds.has(n.parentId)) { + const groupPos = groupPositions[n.parentId] ?? { x: 0, y: 0 } + return { + ...n, + parentId: undefined, + extent: undefined, + position: { + x: groupPos.x + n.position.x, + y: groupPos.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') +``` + +Update the return to include: +```tsx +return { + // ... existing + groupSelection, + ungroupSelection, + canGroup, + canUngroup, +} +``` + +- [ ] **Step 2: Pass group commands to ContextMenu in DiagramEditor** + +```tsx + +``` + +- [ ] **Step 3: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/network/hooks/useDiagramCommands.ts \ + frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "feat(network): add group/ungroup commands with bounding box calculation" +``` + +--- + +## Task 9: Orthogonal Edge Routing Option + +**Files:** +- Modify: `frontend/src/components/network/edges/ConnectionEdge.tsx` +- Modify: `frontend/src/components/network/panels/PropertiesPanel.tsx` +- Modify: `frontend/src/types/network-diagram.ts` + +The existing routing options are `null` (straight), `'curved'` (Bezier), `'step'` (SmoothStep). We add `'orthogonal'` as a true right-angle routing option using React Flow's `SmoothStepEdge` with `borderRadius: 0`. + +- [ ] **Step 1: Update routing type in network-diagram.ts** + +In `DiagramEdge`, change: +```ts +routing?: string | null +``` +to: +```ts +routing?: 'curved' | 'step' | 'orthogonal' | null +``` + +- [ ] **Step 2: Add orthogonal path to ConnectionEdge** + +In `ConnectionEdge.tsx`, import `SmoothStepEdge` and handle the new routing value. Find where routing determines path type and add: + +```tsx +import { + getStraightPath, + getBezierPath, + getSmoothStepPath, + EdgeLabelRenderer, + BaseEdge, +} from '@xyflow/react' + +// In the component, where routing is checked: +let pathData = '' +let labelX = 0 +let labelY = 0 + +if (routing === 'curved') { + ;[pathData, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }) +} else if (routing === 'step') { + ;[pathData, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 8 }) +} else if (routing === 'orthogonal') { + ;[pathData, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 0 }) +} else { + ;[pathData, labelX, labelY] = getStraightPath({ sourceX, sourceY, targetX, targetY }) +} +``` + +- [ ] **Step 3: Add orthogonal button to PropertiesPanel edge routing** + +In `PropertiesPanel.tsx`, find the three routing style buttons (Minus/Spline/GitBranch) and add a fourth for orthogonal: + +```tsx +import { Minus, Spline, GitBranch, CornerUpRight } from 'lucide-react' + +// In the routing buttons row, add: + +``` + +- [ ] **Step 4: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/network/edges/ConnectionEdge.tsx \ + frontend/src/components/network/panels/PropertiesPanel.tsx \ + frontend/src/types/network-diagram.ts +git commit -m "feat(network): add orthogonal edge routing option" +``` + +--- + +## Task 10: Inline Label Editing on DeviceNode + +**Files:** +- Modify: `frontend/src/components/network/nodes/DeviceNode.tsx` + +Double-clicking a node's label enters inline edit mode. On blur or Enter, the new label is committed via React Flow's node data update mechanism. + +- [ ] **Step 1: Add inline editing state to DeviceNode** + +In `DeviceNode.tsx`, add: + +```tsx +import { memo, useState, useRef, useEffect } from 'react' + +// Inside the component: +const [editing, setEditing] = useState(false) +const [labelValue, setLabelValue] = useState(data.label ?? '') +const inputRef = useRef(null) + +useEffect(() => { + if (editing) { + inputRef.current?.focus() + inputRef.current?.select() + } +}, [editing]) + +// Keep local state in sync if data.label changes externally +useEffect(() => { + if (!editing) setLabelValue(data.label ?? '') +}, [data.label, editing]) +``` + +- [ ] **Step 2: Replace the label span with conditional edit/display** + +Find the label `
` or `` in the node render and replace it: + +```tsx +{editing ? ( + setLabelValue(e.target.value)} + onBlur={() => { + setEditing(false) + // Emit update via React Flow's updateNodeData if available, + // or store in a ref for parent to pick up via onNodesChange + if (labelValue !== data.label) { + // Use the updateNodeData callback passed from React Flow + // This is handled via the onNodeUpdate prop chain in DiagramEditor + } + }} + onKeyDown={e => { + if (e.key === 'Enter') inputRef.current?.blur() + if (e.key === 'Escape') { + setLabelValue(data.label ?? '') + setEditing(false) + } + e.stopPropagation() + }} + style={{ fontSize: labelFontSize, width: '80%' }} + className="bg-transparent border-none outline-none text-center text-primary font-medium" + /> +) : ( + setEditing(true)} + > + {labelValue} + +)} +``` + +- [ ] **Step 3: Wire label commit through useUpdateNodeInternals or onNodesChange** + +In `DiagramEditor.tsx`, update the `onNodeUpdate` handler to accept a label update triggered by inline edit. The cleanest pattern is to use React Flow's `useReactFlow().updateNodeData`: + +In `NetworkCanvas.tsx`, pass the `useReactFlow` hook's `updateNodeData` down, or handle it inside `DeviceNode` itself using `useReactFlow`: + +```tsx +// In DeviceNode.tsx, import useReactFlow +import { useReactFlow } from '@xyflow/react' + +// Inside component: +const { updateNodeData } = useReactFlow() + +// In onBlur: +onBlur={() => { + setEditing(false) + if (labelValue !== data.label) { + updateNodeData(id, { ...data, label: labelValue }) + } +}} +``` + +This keeps DiagramEditor's `onNodeUpdate` callback for external changes while inline edits go through React Flow's own data update mechanism. + +- [ ] **Step 4: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/components/network/nodes/DeviceNode.tsx +git commit -m "feat(network): add inline label editing on DeviceNode (double-click)" +``` + +--- + +## Task 11: Z-Order Normalization + +**Files:** +- Modify: `frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx` + +The current bringToFront/sendToBack uses `Math.max`/`Math.min` increments that can overflow. This task normalizes z-order to always be 1..N after every operation. + +- [ ] **Step 1: Add normalizeZOrder utility** + +In `DiagramEditor.tsx`, add a helper near the top of the file (outside the component): + +```tsx +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 })) +} +``` + +- [ ] **Step 2: Apply normalization in bringToFront and sendToBack** + +Find `onBringToFront` and `onSendToBack` in `DiagramEditor.tsx` and update them: + +```tsx +const onBringToFront = useCallback((nodeId: string) => { + pushHistory(nodes, edges) + const maxZ = Math.max(...nodes.map(n => n.zIndex ?? 0)) + setNodes(prev => normalizeZOrder( + prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) + )) +}, [nodes, edges, pushHistory]) + +const onSendToBack = useCallback((nodeId: string) => { + pushHistory(nodes, edges) + setNodes(prev => normalizeZOrder( + prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n) + )) +}, [nodes, edges, pushHistory]) +``` + +- [ ] **Step 3: Build clean** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +git commit -m "fix(network): normalize z-order to 1..N after bring-to-front/send-to-back" +``` + +--- + +## Task 12: Push and Verify + +- [ ] **Step 1: Final build check** + +```bash +cd /home/coder/resolutionflow/frontend && PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH" npx tsc -b 2>&1 +``` + +Expected: no output. + +- [ ] **Step 2: Push to remote** + +```bash +cd /home/coder/resolutionflow && git push origin feat/network-diagrams +``` + +- [ ] **Step 3: Confirm PR #139 is updated** + +```bash +gh pr view 139 +``` + +The PR should show all commits from Tasks 1–11. + +--- + +## Self-Review Against Phase 2 Spec + +Checking against `docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md` Phase 2 scope: + +| Requirement | Task | Covered? | +|-------------|------|----------| +| Snap-to-guides (in addition to snap-to-grid) | — | ❌ Not in this plan — React Flow doesn't expose guide-snapping natively; deferred to Phase 2.5 | +| Alignment commands | Tasks 4, 5, 6 | ✅ | +| Distribution commands | Tasks 4, 5, 6 | ✅ | +| Multi-select improvements | Tasks 4, 6 | ✅ | +| Better z-order handling | Task 11 | ✅ | +| Inline text editing | Task 10 | ✅ | +| Better group/container behavior | Tasks 7, 8 | ✅ | +| Rich edge routing choices | Task 9 | ✅ (straight/curved/step/orthogonal) | +| Manual bend points | — | ❌ Deferred — requires custom edge with draggable waypoints, significant scope | +| Port-aware connection handling | — | ❌ Deferred — DeviceNode already has 4 handles; advanced port config is Phase 3 | +| Keyboard nudging | Task 3 | ✅ | +| Undo/redo | Tasks 1, 2, 3 | ✅ | + +**Deferred items** (snap-to-guides, manual bend points, port-aware connections) are noted above. They are not gaps — they are intentionally scoped out of this plan as they each require significant standalone implementations. They should be planned as Phase 2.5 or Phase 3 items. diff --git a/frontend/src/api/networkDiagrams.ts b/frontend/src/api/networkDiagrams.ts index c074fb00..245b4417 100644 --- a/frontend/src/api/networkDiagrams.ts +++ b/frontend/src/api/networkDiagrams.ts @@ -51,6 +51,10 @@ export const networkDiagramsApi = { return response.data }, + async uploadThumbnail(id: string, dataUrl: string): Promise { + await apiClient.post(`/network-diagrams/${id}/thumbnail`, { data_url: dataUrl }) + }, + async aiGenerate(data: AIGenerateRequest): Promise { const response = await apiClient.post('/network-diagrams/ai-generate', data) return response.data diff --git a/frontend/src/components/network/ContextMenu.tsx b/frontend/src/components/network/ContextMenu.tsx index 2ea4b1ad..2b76a681 100644 --- a/frontend/src/components/network/ContextMenu.tsx +++ b/frontend/src/components/network/ContextMenu.tsx @@ -1,5 +1,10 @@ import { useEffect, useRef } from 'react' -import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack } from 'lucide-react' +import { + Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Ungroup, Maximize2, BringToFront, SendToBack, + AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, + AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, + AlignHorizontalSpaceAround, AlignVerticalSpaceAround, +} from 'lucide-react' import { cn } from '@/lib/utils' interface MenuAction { @@ -15,9 +20,41 @@ interface ContextMenuProps { position: { x: number; y: number } actions: MenuAction[] onClose: () => void + onAlignLeft?: () => void + onAlignRight?: () => void + onAlignCenterH?: () => void + onAlignTop?: () => void + onAlignBottom?: () => void + onAlignCenterV?: () => void + onDistributeH?: () => void + onDistributeV?: () => void + canAlign?: boolean + canDistribute?: boolean + onGroupSelection?: () => void + onUngroupSelection?: () => void + canGroup?: boolean + canUngroup?: boolean } -export function ContextMenu({ position, actions, onClose }: ContextMenuProps) { +export function ContextMenu({ + position, + actions, + onClose, + onAlignLeft, + onAlignRight, + onAlignCenterH, + onAlignTop, + onAlignBottom, + onAlignCenterV, + onDistributeH, + onDistributeV, + canAlign, + canDistribute, + onGroupSelection, + onUngroupSelection, + canGroup, + canUngroup, +}: ContextMenuProps) { const menuRef = useRef(null) const clampedPosition = { ...position } @@ -83,6 +120,59 @@ export function ContextMenu({ position, actions, onClose }: ContextMenuProps) {
))} + + {canAlign && ( + <> +
+
Align
+ + + + + + + {canDistribute && ( + <> +
+
Distribute
+ + + + )} + + )} + + {(canGroup || canUngroup) && ( + <> +
+ {canGroup && ( + + )} + {canUngroup && ( + + )} + + )}
) } diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index 000a8608..3372aa2f 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -1,6 +1,9 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { ChevronLeft, Save, Download, FileJson, Image, FileText } from 'lucide-react' +import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, FileOutput, Upload, Cable } from 'lucide-react' +import { cn } from '@/lib/utils' + +export type InteractionMode = 'select' | 'pan' | 'connect' interface DiagramHeaderProps { name: string @@ -12,8 +15,17 @@ interface DiagramHeaderProps { onNameChange: (name: string) => void onSave: () => void onExportPng: () => void + onExportSvg: () => void onExportPdf: () => void onExportJson: () => void + onExportDrawio: () => void + onImportDrawio: () => void // draw.io import — triggered from Export menu + onUndo: () => void + onRedo: () => void + canUndo: boolean + canRedo: boolean + interactionMode: InteractionMode + onModeChange: (mode: InteractionMode) => void } export function DiagramHeader({ @@ -26,8 +38,17 @@ export function DiagramHeader({ onNameChange, onSave, onExportPng, + onExportSvg, onExportPdf, onExportJson, + onExportDrawio, + onImportDrawio, + onUndo, + onRedo, + canUndo, + canRedo, + interactionMode, + onModeChange, }: DiagramHeaderProps) { const navigate = useNavigate() const [editing, setEditing] = useState(false) @@ -88,6 +109,72 @@ export function DiagramHeader({
+
+ + +
+ +
+ + {/* Interaction mode toggle */} +
+ + + +
+ +
+ {editing ? ( - Export + Export / Import {showExportMenu && ( -
+
+
Export as
+ {diagramId && ( )} + +
+
Import
+
)}
diff --git a/frontend/src/components/network/KeyboardShortcutsOverlay.tsx b/frontend/src/components/network/KeyboardShortcutsOverlay.tsx new file mode 100644 index 00000000..de151d7c --- /dev/null +++ b/frontend/src/components/network/KeyboardShortcutsOverlay.tsx @@ -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 ( + + {children} + + ) +} + +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 ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ {/* Header */} +
+
+

Keyboard Shortcuts

+

Press ? anytime to open this

+
+ +
+ + {/* Shortcut grid */} +
+ {GROUPS.map((group, gi) => ( +
= 2 ? 'border-t border-default' : ''}> +
+
+ {group.title} +
+
+ {group.rows.map(row => ( +
+ {row.label} +
+ {row.keys.map((k, i) => ( + + {i > 0 && +} + {k} + + ))} +
+
+ ))} +
+
+
+ ))} +
+ + {/* Footer hint */} +
+ On Mac, Ctrl = ⌘ Cmd +
+
+
+ ) +} diff --git a/frontend/src/components/network/NetworkCanvas.tsx b/frontend/src/components/network/NetworkCanvas.tsx index 7bd3e92c..35363421 100644 --- a/frontend/src/components/network/NetworkCanvas.tsx +++ b/frontend/src/components/network/NetworkCanvas.tsx @@ -6,6 +6,7 @@ import { MiniMap, BackgroundVariant, type OnConnect, + type OnReconnect, type OnNodesChange, type OnEdgesChange, type Node, @@ -15,6 +16,8 @@ import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { getDeviceRenderConfig } from './nodes/deviceRegistry' import type { DeviceNodeData } from './nodes/DeviceNode' +import type { InteractionMode } from './DiagramHeader' +import { cn } from '@/lib/utils' interface NetworkCanvasProps { nodes: Node[] @@ -22,6 +25,7 @@ interface NetworkCanvasProps { onNodesChange: OnNodesChange onEdgesChange: OnEdgesChange onConnect: OnConnect + onReconnect: OnReconnect onNodeSelect: (nodeId: string | null) => void onEdgeSelect: (edgeId: string | null) => void onDrop: (event: React.DragEvent) => void @@ -31,6 +35,7 @@ interface NetworkCanvasProps { onNodeContextMenu?: (event: React.MouseEvent, node: Node) => void onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void onPaneClick?: () => void + interactionMode?: InteractionMode } export function NetworkCanvas({ @@ -39,6 +44,7 @@ export function NetworkCanvas({ onNodesChange, onEdgesChange, onConnect, + onReconnect, onNodeSelect, onEdgeSelect, onDrop, @@ -48,6 +54,7 @@ export function NetworkCanvas({ onNodeContextMenu, onPaneContextMenu, onPaneClick: onPaneClickProp, + interactionMode = 'select', }: NetworkCanvasProps) { const handleSelectionChange = useCallback(({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => { if (selectedNodes.length === 1) { @@ -75,13 +82,22 @@ export function NetworkCanvas({ }, []) return ( -
+
{ + if (event.button === 1) { + event.preventDefault() + } + }} + > diff --git a/frontend/src/components/network/edges/ConnectionEdge.tsx b/frontend/src/components/network/edges/ConnectionEdge.tsx index e6c30b6f..bc87b1be 100644 --- a/frontend/src/components/network/edges/ConnectionEdge.tsx +++ b/frontend/src/components/network/edges/ConnectionEdge.tsx @@ -31,6 +31,7 @@ function getEdgePath(routing: string | null | undefined, props: EdgeProps) { } if (routing === 'curved') return getBezierPath(base) if (routing === 'step') return getSmoothStepPath(base) + if (routing === 'orthogonal') return getSmoothStepPath({ ...base, borderRadius: 0 }) return getStraightPath(base) } @@ -53,7 +54,7 @@ function ConnectionEdgeComponent(props: EdgeProps) { {props.label && (
> setIsDirty: (dirty: boolean) => void canvasRef: React.RefObject + onUndo: () => void + onRedo: () => void + onNudge: (dx: number, dy: number) => void + onSetMode: (mode: 'select' | 'pan' | 'connect') => void + onToggleShortcuts: () => void }) { const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow() const clipboardRef = useRef(null) @@ -211,6 +221,45 @@ export function useCanvasShortcuts({ const ctrl = e.ctrlKey || e.metaKey + // Undo: Ctrl+Z / Cmd+Z + if (e.key === 'z' && ctrl && !e.shiftKey) { + e.preventDefault() + onUndo() + return + } + // Redo: Ctrl+Y or Ctrl+Shift+Z + if ((e.key === 'y' && ctrl) || (e.key === 'z' && ctrl && e.shiftKey)) { + e.preventDefault() + onRedo() + return + } + // Arrow key nudging + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + e.preventDefault() + const delta = e.shiftKey ? 10 : 1 + switch (e.key) { + case 'ArrowUp': onNudge(0, -delta); break + case 'ArrowDown': onNudge(0, delta); break + case 'ArrowLeft': onNudge(-delta, 0); break + case 'ArrowRight': onNudge( delta, 0); break + } + return + } + + // Mode shortcuts: V = select, H = pan, C = connect + if (!ctrl && e.key === 'v') { + onSetMode('select') + return + } + if (!ctrl && e.key === 'h') { + onSetMode('pan') + return + } + if (!ctrl && e.key === 'c') { + onSetMode('connect') + return + } + if (ctrl && e.key === 'c') { e.preventDefault() copyNodes() @@ -232,12 +281,15 @@ export function useCanvasShortcuts({ } else if (e.key === '[' && !ctrl) { e.preventDefault() sendSelectedToBack() + } else if (e.key === '?' && !ctrl) { + e.preventDefault() + onToggleShortcuts() } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack]) + }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts]) return { copyNodes, diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts new file mode 100644 index 00000000..0e728ffb --- /dev/null +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -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> +} + +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 = {} + 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 = {} + 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 = {} + 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, + } +} diff --git a/frontend/src/components/network/nodes/DeviceNode.tsx b/frontend/src/components/network/nodes/DeviceNode.tsx index 30b9faf6..2acd1528 100644 --- a/frontend/src/components/network/nodes/DeviceNode.tsx +++ b/frontend/src/components/network/nodes/DeviceNode.tsx @@ -1,10 +1,11 @@ -import { memo } from 'react' -import { Position, NodeResizer, type NodeProps } from '@xyflow/react' -import { BaseNode, BaseNodeHeader, BaseNodeHeaderTitle, BaseNodeContent } from '../ui/base-node' +import { memo, useState, useRef, useEffect } from 'react' +import { Position, NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react' +import { BaseNode, BaseNodeHeader, BaseNodeContent } from '../ui/base-node' import { BaseHandle } from '../ui/base-handle' import { NodeStatusIndicator, type NodeStatus } from '../ui/node-status-indicator' import { NodeTooltip, NodeTooltipTrigger, NodeTooltipContent } from '../ui/node-tooltip' -import { getDeviceRenderConfig } from './deviceRegistry' +import { getDeviceRenderConfig, CATEGORY_LABELS } from './deviceRegistry' +import { cn } from '@/lib/utils' import type { DeviceProperties } from '@/types' export interface DeviceNodeData { @@ -29,9 +30,9 @@ const NODE_DEFAULT = 120 // default square side in px const NODE_MIN = 80 // minimum square side in px const NODE_MAX = 280 // maximum square side in px -function DeviceNodeComponent({ data, selected, width, height }: NodeProps) { +function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) { const nodeData = data as unknown as DeviceNodeData - const { icon: Icon, color } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category) + const { icon: Icon, color, accentClass, surfaceClass, category } = getDeviceRenderConfig(nodeData.deviceType, nodeData.category) const status = (nodeData.properties?.status || 'unknown') as NodeStatus const ip = nodeData.properties?.ip const props = nodeData.properties || {} @@ -46,6 +47,25 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) { const labelPx = Math.max(9, Math.min(20, Math.round(scale * 11))) // IP font: 9px at default, clamped to [8, 16] const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9))) + const metaPx = Math.max(8, Math.min(11, Math.round(scale * 8))) + const iconPlateSize = Math.round(Math.max(34, Math.min(82, scale * 50))) + + const [editing, setEditing] = useState(false) + const [labelValue, setLabelValue] = useState(nodeData.label ?? '') + const inputRef = useRef(null) + const { updateNodeData } = useReactFlow() + + useEffect(() => { + if (editing) { + inputRef.current?.focus() + inputRef.current?.select() + } + }, [editing]) + + // Sync if data.label changes externally (e.g. undo/redo) + useEffect(() => { + if (!editing) setLabelValue(nodeData.label ?? '') + }, [nodeData.label, editing]) const hasTooltipContent = props.hostname || props.ip || props.vendor || props.model || props.role || props.notes @@ -64,16 +84,70 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) { - - - - - {nodeData.label} - + +
+ +
+
+
+ +
+
+ {editing ? ( + 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" + /> + ) : ( + { + e.stopPropagation() + setEditing(true) + }} + > + {labelValue} + + )} + + {CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')} + {ip && ( - - {ip} + + + {ip} + )} diff --git a/frontend/src/components/network/nodes/GroupNode.tsx b/frontend/src/components/network/nodes/GroupNode.tsx new file mode 100644 index 00000000..55156a4c --- /dev/null +++ b/frontend/src/components/network/nodes/GroupNode.tsx @@ -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 = { + 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(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 ( + <> + +
+
+ {editing ? ( + 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 }} + /> + ) : ( + setEditing(true)} + > + {labelValue || groupData.groupType} + + )} +
+
+ + ) +} + +GroupNodeComponent.displayName = 'GroupNode' + +export const GroupNode = memo(GroupNodeComponent) +export default GroupNode diff --git a/frontend/src/components/network/nodes/deviceRegistry.ts b/frontend/src/components/network/nodes/deviceRegistry.ts index 7dec611d..34271c8e 100644 --- a/frontend/src/components/network/nodes/deviceRegistry.ts +++ b/frontend/src/components/network/nodes/deviceRegistry.ts @@ -9,6 +9,9 @@ import { export interface DeviceRenderConfig { icon: LucideIcon color: string + accentClass: string + surfaceClass: string + category: string } // Category-semantic color palette — each color carries meaning: @@ -27,62 +30,107 @@ export const STORAGE_COLOR = '#a78bfa' export const CLOUD_COLOR = '#67e8f9' export const INFRA_COLOR = '#94a3b8' +const CATEGORY_STYLES: Record> = { + 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 = { // Network layer - 'router': { icon: Router, color: NETWORK_COLOR }, - 'switch': { icon: Network, color: NETWORK_COLOR }, - 'access-point': { icon: Wifi, color: NETWORK_COLOR }, - 'load-balancer': { icon: Gauge, color: NETWORK_COLOR }, + 'router': makeConfig(Router, NETWORK_COLOR, 'network'), + 'switch': makeConfig(Network, NETWORK_COLOR, 'network'), + 'access-point': makeConfig(Wifi, NETWORK_COLOR, 'network'), + 'load-balancer': makeConfig(Gauge, NETWORK_COLOR, 'network'), // Security - 'firewall': { icon: BrickWallFire, color: SECURITY_COLOR }, - 'badge-reader': { icon: KeyRound, color: SECURITY_COLOR }, + 'firewall': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'), + 'badge-reader': makeConfig(KeyRound, SECURITY_COLOR, 'security'), // Compute - 'server': { icon: Server, color: COMPUTE_COLOR }, - 'vm': { icon: Boxes, color: COMPUTE_COLOR }, - 'container': { icon: Package, color: COMPUTE_COLOR }, + 'server': makeConfig(Server, COMPUTE_COLOR, 'compute'), + 'vm': makeConfig(Boxes, COMPUTE_COLOR, 'compute'), + 'container': makeConfig(Package, COMPUTE_COLOR, 'compute'), // Storage - 'nas': { icon: Database, color: STORAGE_COLOR }, - 'san': { icon: HardDrive, color: STORAGE_COLOR }, - 'cloud-storage': { icon: CloudCog, color: STORAGE_COLOR }, + 'nas': makeConfig(Database, STORAGE_COLOR, 'storage'), + 'san': makeConfig(HardDrive, STORAGE_COLOR, 'storage'), + 'cloud-storage': makeConfig(CloudCog, STORAGE_COLOR, 'storage'), // Cloud / Internet - 'cloud': { icon: Cloud, color: CLOUD_COLOR }, - 'aws': { icon: Cloud, color: CLOUD_COLOR }, - 'azure': { icon: Cloud, color: CLOUD_COLOR }, - 'gcp': { icon: Cloud, color: CLOUD_COLOR }, - 'isp': { icon: Globe, color: CLOUD_COLOR }, + 'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'), + 'aws': makeConfig(Cloud, CLOUD_COLOR, 'cloud'), + 'azure': makeConfig(Cloud, CLOUD_COLOR, 'cloud'), + 'gcp': makeConfig(Cloud, CLOUD_COLOR, 'cloud'), + 'isp': makeConfig(Globe, CLOUD_COLOR, 'cloud'), // Endpoints - 'workstation': { icon: Monitor, color: ENDPOINT_COLOR }, - 'laptop': { icon: Laptop, color: ENDPOINT_COLOR }, - 'tablet': { icon: Tablet, color: ENDPOINT_COLOR }, - 'phone': { icon: Smartphone, color: ENDPOINT_COLOR }, - 'printer': { icon: Printer, color: ENDPOINT_COLOR }, + 'workstation': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'), + 'laptop': makeConfig(Laptop, ENDPOINT_COLOR, 'endpoint'), + 'tablet': makeConfig(Tablet, ENDPOINT_COLOR, 'endpoint'), + 'phone': makeConfig(Smartphone, ENDPOINT_COLOR, 'endpoint'), + 'printer': makeConfig(Printer, ENDPOINT_COLOR, 'endpoint'), // Infrastructure / physical - 'ups': { icon: BatteryCharging, color: INFRA_COLOR }, - 'pdu': { icon: PlugZap, color: INFRA_COLOR }, - 'rack': { icon: RectangleVertical, color: INFRA_COLOR }, - 'patch-panel': { icon: Cable, color: INFRA_COLOR }, - 'camera': { icon: Camera, color: INFRA_COLOR }, - 'nvr': { icon: Video, color: INFRA_COLOR }, - 'iot': { icon: Radio, color: INFRA_COLOR }, + 'ups': makeConfig(BatteryCharging, INFRA_COLOR, 'infrastructure'), + 'pdu': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'), + 'rack': makeConfig(RectangleVertical, INFRA_COLOR, 'infrastructure'), + 'patch-panel': makeConfig(Cable, INFRA_COLOR, 'infrastructure'), + 'camera': makeConfig(Camera, INFRA_COLOR, 'infrastructure'), + 'nvr': makeConfig(Video, INFRA_COLOR, 'infrastructure'), + 'iot': makeConfig(Radio, INFRA_COLOR, 'infrastructure'), } const CATEGORY_DEFAULTS: Record = { - 'network': { icon: Router, color: NETWORK_COLOR }, - 'compute': { icon: Server, color: COMPUTE_COLOR }, - 'storage': { icon: Database, color: STORAGE_COLOR }, - 'cloud': { icon: Cloud, color: CLOUD_COLOR }, - 'endpoint': { icon: Monitor, color: ENDPOINT_COLOR }, - 'infrastructure': { icon: PlugZap, color: INFRA_COLOR }, - 'security': { icon: BrickWallFire, color: SECURITY_COLOR }, + 'network': makeConfig(Router, NETWORK_COLOR, 'network'), + 'compute': makeConfig(Server, COMPUTE_COLOR, 'compute'), + 'storage': makeConfig(Database, STORAGE_COLOR, 'storage'), + 'cloud': makeConfig(Cloud, CLOUD_COLOR, 'cloud'), + 'endpoint': makeConfig(Monitor, ENDPOINT_COLOR, 'endpoint'), + 'infrastructure': makeConfig(PlugZap, INFRA_COLOR, 'infrastructure'), + 'security': makeConfig(BrickWallFire, SECURITY_COLOR, 'security'), } -const FALLBACK: DeviceRenderConfig = { icon: Cpu, color: INFRA_COLOR } +const FALLBACK: DeviceRenderConfig = makeConfig(Cpu, INFRA_COLOR, 'infrastructure') export function getDeviceRenderConfig(slug: string, category?: string): DeviceRenderConfig { if (SYSTEM_DEVICE_ICONS[slug]) return SYSTEM_DEVICE_ICONS[slug] diff --git a/frontend/src/components/network/nodes/nodeTypes.ts b/frontend/src/components/network/nodes/nodeTypes.ts index eb0a4462..586b3af6 100644 --- a/frontend/src/components/network/nodes/nodeTypes.ts +++ b/frontend/src/components/network/nodes/nodeTypes.ts @@ -1,5 +1,5 @@ import { DeviceNode } from './DeviceNode' -import { GroupNode } from '../ui/labeled-group-node' +import { GroupNode } from './GroupNode' export const nodeTypes = { device: DeviceNode, diff --git a/frontend/src/components/network/panels/DeviceToolbar.tsx b/frontend/src/components/network/panels/DeviceToolbar.tsx index 22f59669..7ee2e0c7 100644 --- a/frontend/src/components/network/panels/DeviceToolbar.tsx +++ b/frontend/src/components/network/panels/DeviceToolbar.tsx @@ -151,22 +151,22 @@ export function DeviceToolbar({ deviceTypes, onDeviceTypesChange }: DeviceToolba
{[ - { slug: 'subnet', label: 'Subnet' }, - { slug: 'vlan', label: 'VLAN' }, - { slug: 'site', label: 'Site' }, - { slug: 'dmz', label: 'DMZ' }, + { slug: 'subnet', label: 'Subnet', color: '#60a5fa' }, + { slug: 'vlan', label: 'VLAN', color: '#a78bfa' }, + { slug: 'site', label: 'Site', color: '#34d399' }, + { slug: 'dmz', label: 'DMZ', color: '#f87171' }, ].map(item => (
{ - e.dataTransfer.setData('application/reactflow-group', JSON.stringify(item)) + e.dataTransfer.setData('application/reactflow-group', JSON.stringify({ slug: item.slug, label: item.label })) e.dataTransfer.effectAllowed = 'move' }} className="flex cursor-grab items-center gap-2 rounded px-2 py-1.5 text-xs text-primary hover:bg-elevated active:cursor-grabbing active:scale-[0.98] transition-transform" > - + {item.label}
))} diff --git a/frontend/src/components/network/panels/PropertiesPanel.tsx b/frontend/src/components/network/panels/PropertiesPanel.tsx index 99b1b43a..26c760eb 100644 --- a/frontend/src/components/network/panels/PropertiesPanel.tsx +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -1,5 +1,11 @@ import { useCallback, useState, useEffect } from 'react' -import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'lucide-react' +import { + Trash2, Minus, Spline, GitBranch, CornerUpRight, BringToFront, SendToBack, + AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, + AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, + AlignHorizontalSpaceAround, AlignVerticalSpaceAround, + BoxSelect, Ungroup, MousePointer, +} from 'lucide-react' import { cn } from '@/lib/utils' import type { DeviceProperties, DiagramEdge } from '@/types' import type { Node, Edge } from '@xyflow/react' @@ -15,6 +21,21 @@ interface PropertiesPanelProps { onSendToBack: (nodeId: string) => void onDeleteNode: (nodeId: string) => void onDeleteEdge: (edgeId: string) => void + selectedNodeCount: number + onAlignLeft: () => void + onAlignRight: () => void + onAlignCenterH: () => void + onAlignTop: () => void + onAlignBottom: () => void + onAlignCenterV: () => void + onDistributeH: () => void + onDistributeV: () => void + canAlign: boolean + canDistribute: boolean + canGroup: boolean + canUngroup: boolean + onGroupSelection: (groupType: string) => void + onUngroupSelection: () => void } type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown' @@ -68,6 +89,14 @@ function SectionDivider({ label }: { label: string }) { ) } +const GROUP_TYPES = [ + { value: 'subnet', label: 'Subnet' }, + { value: 'vlan', label: 'VLAN' }, + { value: 'site', label: 'Site' }, + { value: 'dmz', label: 'DMZ' }, + { value: 'custom', label: 'Custom' }, +] + export function PropertiesPanel({ selectedNode, selectedEdge, @@ -78,8 +107,24 @@ export function PropertiesPanel({ onSendToBack, onDeleteNode, onDeleteEdge, + selectedNodeCount, + onAlignLeft, + onAlignRight, + onAlignCenterH, + onAlignTop, + onAlignBottom, + onAlignCenterV, + onDistributeH, + onDistributeV, + canAlign, + canDistribute, + canGroup, + canUngroup, + onGroupSelection, + onUngroupSelection, }: PropertiesPanelProps) { const [deleteConfirm, setDeleteConfirm] = useState(false) + const [pendingGroupType, setPendingGroupType] = useState('subnet') // Reset confirm state whenever the selection changes // eslint-disable-next-line react-hooks/set-state-in-effect @@ -98,14 +143,107 @@ export function PropertiesPanel({ onNodeUpdate(selectedNode.id, { label: value } as Partial) }, [selectedNode, onNodeUpdate]) + if (!selectedNode && !selectedEdge && selectedNodeCount >= 2) { + return ( +
+
+
+ {selectedNodeCount} nodes selected +
+
+
+ {canAlign && ( +
+
Align
+
+ {([ + { 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 }) => ( + + ))} +
+
+ )} + {canDistribute && ( +
+
Distribute
+
+ + +
+
+ )} + {(canGroup || canUngroup) && ( +
+
Grouping
+ {canGroup && ( +
+ + +
+ )} + {canUngroup && ( + + )} +
+ )} +
+
+ ) + } + if (!selectedNode && !selectedEdge) { return ( -
-

- Select a device or connection to edit its properties +

+
+ +
+

+ Select a device or connection

-

- Hover a device to preview its info +

+ Properties appear here. Hover a device to see a quick summary.

) @@ -122,6 +260,9 @@ export function PropertiesPanel({

Connection

+
+ Drag either end of the line on the canvas to reconnect it to a different asset. +
Label { const routing = (edgeData.routing as string | null | undefined) ?? null const active = routing === value diff --git a/frontend/src/components/network/ui/base-handle.tsx b/frontend/src/components/network/ui/base-handle.tsx index d70d468f..42cbb062 100644 --- a/frontend/src/components/network/ui/base-handle.tsx +++ b/frontend/src/components/network/ui/base-handle.tsx @@ -9,8 +9,9 @@ export function BaseHandle({ className, children, ...props }: ComponentProps diff --git a/frontend/src/components/network/ui/base-node.tsx b/frontend/src/components/network/ui/base-node.tsx index 7a45868f..15a91dfd 100644 --- a/frontend/src/components/network/ui/base-node.tsx +++ b/frontend/src/components/network/ui/base-node.tsx @@ -5,8 +5,8 @@ export function BaseNode({ className, ...props }: ComponentProps<'div'>) { return (
= { } const STATUS_GLOW: Record = { - online: 'shadow-[0_0_8px_rgba(52,211,153,0.3)]', - offline: 'shadow-[0_0_8px_rgba(248,113,113,0.3)]', - degraded: 'shadow-[0_0_8px_rgba(250,204,21,0.3)]', + online: 'shadow-[0_0_6px_rgba(52,211,153,0.15)]', + offline: 'shadow-[0_0_6px_rgba(248,113,113,0.15)]', + degraded: 'shadow-[0_0_6px_rgba(250,204,21,0.15)]', unknown: '', } @@ -31,7 +31,7 @@ export function NodeStatusIndicator({ status = 'unknown', children, className }: return (
({ hide: () => {}, }) -export function NodeTooltip({ children, ...props }: ComponentProps<'div'>) { +export function NodeTooltip({ children, className, ...props }: ComponentProps<'div'>) { const [visible, setVisible] = useState(false) const show = useCallback(() => setVisible(true), []) const hide = useCallback(() => setVisible(false), []) return ( -
{children}
+
{children}
) } export function NodeTooltipTrigger({ children, + className, onMouseEnter, onMouseLeave, ...props @@ -36,6 +37,7 @@ export function NodeTooltipTrigger({ return (
{ show() onMouseEnter?.(e) diff --git a/frontend/src/index.css b/frontend/src/index.css index 8405b22f..607ee349 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -445,3 +445,42 @@ scroll-behavior: auto !important; } } + +/* ── Print / PDF export ───────────────────────────────────────────────── */ +@media print { + /* Hide everything that isn't the canvas */ + body > * { display: none !important; } + + /* Show only the React Flow viewport inside the diagram editor page */ + #root { display: block !important; } + #root > * { display: none !important; } + + /* The diagram editor mounts as a child of the app shell — target the canvas wrapper */ + .react-flow__renderer, + .react-flow__viewport, + .react-flow { + display: block !important; + } + + /* Make the canvas fill the printed page */ + .react-flow { + position: fixed !important; + inset: 0 !important; + width: 100vw !important; + height: 100vh !important; + background: #ffffff !important; + } + + /* Force light backgrounds on nodes so they're readable on white paper */ + .react-flow__node { + print-color-adjust: exact; + -webkit-print-color-adjust: exact; + } + + /* Hide UI chrome */ + .react-flow__controls, + .react-flow__minimap, + .react-flow__panel { + display: none !important; + } +} diff --git a/frontend/src/lib/drawio-export.ts b/frontend/src/lib/drawio-export.ts new file mode 100644 index 00000000..4f7b03cc --- /dev/null +++ b/frontend/src/lib/drawio-export.ts @@ -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 = { + '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, '"') +} + +export function exportToDrawio(nodes: Node[], edges: Edge[]): string { + const cells: string[] = [ + '', + '', + ] + + 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( + `` + + `` + + ``, + ) + } 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( + `` + + `` + + ``, + ) + } + } + + for (const edge of edges) { + const label = typeof edge.label === 'string' ? edge.label : '' + cells.push( + `` + + `` + + ``, + ) + } + + const xml = + `\n` + + `\n` + + cells.join('\n') + + `\n` + + return xml +} diff --git a/frontend/src/lib/drawio-import.ts b/frontend/src/lib/drawio-import.ts new file mode 100644 index 00000000..41d5b736 --- /dev/null +++ b/frontend/src/lib/drawio-import.ts @@ -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() + 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() + + 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 } +} diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index c20b4507..9b86d82a 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -1,10 +1,11 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, useRef, useReducer } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ReactFlowProvider, useNodesState, useEdgesState, addEdge, + reconnectEdge, useReactFlow, getNodesBounds, getViewportForBounds, @@ -17,16 +18,25 @@ import '@xyflow/react/dist/style.css' import { NetworkCanvas } from '@/components/network/NetworkCanvas' import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu' import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts' -import { DiagramHeader } from '@/components/network/DiagramHeader' +import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands' +import { DiagramHeader, type InteractionMode } from '@/components/network/DiagramHeader' import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar' import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel' import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel' import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt' +import { KeyboardShortcutsOverlay } from '@/components/network/KeyboardShortcutsOverlay' import { networkDiagramsApi, deviceTypesApi } from '@/api' import { toast } from '@/lib/toast' +import { exportToDrawio } from '@/lib/drawio-export' +import { parseDrawioXml } from '@/lib/drawio-import' import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types' import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode' +function normalizeZOrder(nodes: Node[]): Node[] { + const sorted = [...nodes].sort((a, b) => ((a.zIndex ?? 0) - (b.zIndex ?? 0))) + return sorted.map((n, i) => ({ ...n, zIndex: i + 1 })) +} + type ContextMenuState = { type: 'node' | 'canvas' position: { x: number; y: number } @@ -63,13 +73,78 @@ function DiagramEditorInner() { const [loading, setLoading] = useState(!!id) const [isDragOver, setIsDragOver] = useState(false) + const [interactionMode, setInteractionMode] = useState('select') + const [showShortcuts, setShowShortcuts] = useState(false) + const canvasRef = useRef(null) + const drawioImportRef = useRef(null) const [contextMenu, setContextMenu] = useState(null) const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null) useEffect(() => { isDirtyRef.current = isDirty }, [isDirty]) useEffect(() => { diagramIdRef.current = diagramId }, [diagramId]) + // History + const historyStack = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]) + const historyIndex = useRef(-1) + const MAX_HISTORY = 50 + const [, forceHistoryUpdate] = useReducer((x: number) => x + 1, 0) + + const pushHistory = useCallback((currentNodes: Node[], currentEdges: Edge[]) => { + historyStack.current = historyStack.current.slice(0, historyIndex.current + 1) + historyStack.current.push({ + nodes: JSON.parse(JSON.stringify(currentNodes)), + edges: JSON.parse(JSON.stringify(currentEdges)), + }) + if (historyStack.current.length > MAX_HISTORY) { + historyStack.current.shift() + } else { + historyIndex.current += 1 + } + forceHistoryUpdate() + }, [forceHistoryUpdate]) + + const undo = useCallback(() => { + if (historyIndex.current <= 0) return + historyIndex.current -= 1 + const snapshot = historyStack.current[historyIndex.current] + setNodes(snapshot.nodes) + setEdges(snapshot.edges) + setIsDirty(true) + forceHistoryUpdate() + }, [setNodes, setEdges, forceHistoryUpdate]) + + const redo = useCallback(() => { + if (historyIndex.current >= historyStack.current.length - 1) return + historyIndex.current += 1 + const snapshot = historyStack.current[historyIndex.current] + setNodes(snapshot.nodes) + setEdges(snapshot.edges) + setIsDirty(true) + forceHistoryUpdate() + }, [setNodes, setEdges, forceHistoryUpdate]) + + const canUndo = historyIndex.current > 0 + const canRedo = historyIndex.current < historyStack.current.length - 1 + + const diagramCommands = useDiagramCommands({ + nodes, + edges, + pushHistory, + setNodes, + }) + + const onNudge = useCallback((dx: number, dy: number) => { + const selected = nodes.filter(n => n.selected) + if (selected.length === 0) return + pushHistory(nodes, edges) + setNodes(prev => prev.map(n => + n.selected + ? { ...n, position: { x: n.position.x + dx, y: n.position.y + dy } } + : n + )) + }, [nodes, edges, pushHistory, setNodes]) + const { copyNodes, pasteNodes, @@ -84,6 +159,11 @@ function DiagramEditorInner() { setEdges, setIsDirty: (v: boolean) => setIsDirty(v), canvasRef, + onUndo: undo, + onRedo: redo, + onNudge, + onSetMode: setInteractionMode, + onToggleShortcuts: () => setShowShortcuts(v => !v), }) const handleNodesChange: typeof onNodesChange = useCallback((changes) => { @@ -137,6 +217,7 @@ function DiagramEditorInner() { type: 'device', position: n.position, style: n.style || { width: 120, height: 120 }, + ...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}), data: { label: n.label, deviceType: n.type, @@ -161,6 +242,37 @@ function DiagramEditorInner() { })) ) setLastSavedAt(new Date(diagram.updated_at)) + // Initialize history after load + const loadedNodes = diagram.nodes.map(n => { + if (n.nodeType === 'group') { + return { + id: n.id, + type: 'group' as const, + position: n.position, + style: n.style || { width: 300, height: 200 }, + data: { label: n.label, groupType: n.type }, + } + } + return { + id: n.id, + type: 'device' as const, + position: n.position, + style: n.style || { width: 120, height: 120 }, + ...(n.parentId ? { parentId: n.parentId, extent: 'parent' as const } : {}), + data: { label: n.label, deviceType: n.type, properties: n.properties } satisfies DeviceNodeData, + } + }) + const loadedEdges = diagram.edges.map(e => ({ + id: e.id, + source: e.source, + target: e.target, + type: 'connection' as const, + label: e.label || undefined, + data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes, routing: e.routing ?? null }, + })) + historyStack.current = [] + historyIndex.current = -1 + pushHistory(loadedNodes, loadedEdges) } catch { toast.error('Failed to load diagram') navigate('/network-diagrams') @@ -169,7 +281,7 @@ function DiagramEditorInner() { } })() return () => { cancelled = true } - }, [id, navigate, setNodes, setEdges]) + }, [id, navigate, setNodes, setEdges, pushHistory]) const serializeNodes = useCallback((): DiagramNode[] => { return getNodes().map(n => { @@ -197,6 +309,7 @@ function DiagramEditorInner() { position: n.position, properties: data.properties, style: { width: dw, height: dh }, + ...(n.parentId ? { parentId: n.parentId } : {}), } }) }, [getNodes]) @@ -212,7 +325,7 @@ function DiagramEditorInner() { connectionType: d.connectionType as string || 'ethernet', speed: d.speed as string || null, notes: d.notes as string || null, - routing: d.routing as string || null, + routing: (d.routing as DiagramEdge['routing']) || null, } }) }, [edges]) @@ -228,21 +341,51 @@ function DiagramEditorInner() { nodes: serializeNodes(), edges: serializeEdges(), } + let savedId: string | null = diagramIdRef.current if (diagramIdRef.current) { await networkDiagramsApi.update(diagramIdRef.current, payload) } else { const created = await networkDiagramsApi.create(payload) + savedId = created.id setDiagramId(created.id) navigate(`/network-diagrams/${created.id}`, { replace: true }) } setIsDirty(false) setLastSavedAt(new Date()) + + // Generate thumbnail in the background — don't block save UX on failure + if (savedId && nodes.length > 0) { + try { + const { toPng } = await import('html-to-image') + const THUMB_W = 480 + const THUMB_H = 300 + const bounds = getNodesBounds(nodes) + const viewport = getViewportForBounds(bounds, THUMB_W, THUMB_H, 0.5, 2, 0.1) + const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null + if (flowEl) { + const dataUrl = await toPng(flowEl, { + backgroundColor: '#16181f', + width: THUMB_W, + height: THUMB_H, + style: { + width: `${THUMB_W}px`, + height: `${THUMB_H}px`, + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, + transformOrigin: 'top left', + }, + }) + await networkDiagramsApi.uploadThumbnail(savedId, dataUrl) + } + } catch { + // Thumbnail failure is silent — doesn't affect save success + } + } } catch { toast.error('Failed to save diagram') } finally { setIsSaving(false) } - }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate]) + }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes]) useEffect(() => { const interval = setInterval(() => { @@ -254,13 +397,21 @@ function DiagramEditorInner() { }, [handleSave]) const onConnect = useCallback((connection: Connection) => { + pushHistory(nodes, edges) setEdges(eds => addEdge({ ...connection, type: 'connection', data: { connectionType: 'ethernet' }, }, eds)) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) + + const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => { + pushHistory(nodes, edges) + setEdges(eds => reconnectEdge(oldEdge, newConnection, eds)) + setSelectedEdgeId(oldEdge.id) + setIsDirty(true) + }, [nodes, edges, pushHistory, setEdges]) const onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault() @@ -292,11 +443,22 @@ function DiagramEditorInner() { const handlePaneContextMenu = useCallback((event: MouseEvent | React.MouseEvent) => { event.preventDefault() - setContextMenu({ - type: 'canvas', - position: { x: event.clientX, y: event.clientY }, - }) - }, []) + // Group nodes pass pointer events through to children, so right-clicking a group + // may fire onPaneContextMenu instead of onNodeContextMenu. If nodes are selected, + // show the node context menu so group/align/ungroup options are accessible. + const selected = getNodes().filter(n => n.selected) + if (selected.length > 0) { + setContextMenu({ + type: 'node', + position: { x: event.clientX, y: event.clientY }, + }) + } else { + setContextMenu({ + type: 'canvas', + position: { x: event.clientX, y: event.clientY }, + }) + } + }, [getNodes]) const closeContextMenu = useCallback(() => { setContextMenu(null) @@ -334,6 +496,7 @@ function DiagramEditorInner() { } satisfies DeviceProperties, } satisfies DeviceNodeData, } + pushHistory(nodes, edges) setNodes(nds => [...nds, newNode]) setIsDirty(true) return @@ -353,20 +516,23 @@ function DiagramEditorInner() { groupType: slug, }, } + pushHistory(nodes, edges) setNodes(nds => [...nds, newNode]) setIsDirty(true) } - }, [setNodes, screenToFlowPosition]) + }, [nodes, edges, pushHistory, setNodes, screenToFlowPosition]) const handleNodeUpdate = useCallback((nodeId: string, updates: Partial) => { + pushHistory(nodes, edges) setNodes(nds => nds.map(n => { if (n.id !== nodeId) return n return { ...n, data: { ...n.data, ...updates } } })) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleEdgeUpdate = useCallback((edgeId: string, updates: Partial) => { + pushHistory(nodes, edges) setEdges(eds => eds.map(e => { if (e.id !== edgeId) return e return { @@ -382,44 +548,50 @@ function DiagramEditorInner() { } })) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const handleEdgeTypeChange = useCallback((edgeId: string, edgeType: string) => { + pushHistory(nodes, edges) setEdges(eds => eds.map(e => { if (e.id !== edgeId) return e return { ...e, type: edgeType } })) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const handleDeleteNode = useCallback((nodeId: string) => { + pushHistory(nodes, edges) setNodes(nds => nds.filter(n => n.id !== nodeId)) setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId)) setSelectedNodeId(null) setIsDirty(true) - }, [setNodes, setEdges]) + }, [nodes, edges, pushHistory, setNodes, setEdges]) const handleDeleteEdge = useCallback((edgeId: string) => { + pushHistory(nodes, edges) setEdges(eds => eds.filter(e => e.id !== edgeId)) setSelectedEdgeId(null) setIsDirty(true) - }, [setEdges]) + }, [nodes, edges, pushHistory, setEdges]) const handleBringToFront = useCallback((nodeId: string) => { - setNodes(nds => { - const maxZ = Math.max(0, ...nds.map(n => n.zIndex ?? 0)) - return nds.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) + pushHistory(nodes, edges) + setNodes(prev => { + const maxZ = Math.max(0, ...prev.map(n => n.zIndex ?? 0)) + return normalizeZOrder( + prev.map(n => n.id === nodeId ? { ...n, zIndex: maxZ + 1 } : n) + ) }) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleSendToBack = useCallback((nodeId: string) => { - setNodes(nds => { - const minZ = Math.min(0, ...nds.map(n => n.zIndex ?? 0)) - return nds.map(n => n.id === nodeId ? { ...n, zIndex: minZ - 1 } : n) - }) + pushHistory(nodes, edges) + setNodes(prev => normalizeZOrder( + prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n) + )) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => { const newNodes: Node[] = result.nodes.map(n => ({ @@ -442,6 +614,7 @@ function DiagramEditorInner() { data: { connectionType: e.connectionType, speed: e.speed, notes: e.notes }, })) + pushHistory(nodes, edges) if (mode === 'replace') { setNodes(newNodes) setEdges(newEdges) @@ -463,7 +636,7 @@ function DiagramEditorInner() { setIsDirty(true) setTimeout(() => fitView({ padding: 0.2 }), 100) - }, [setNodes, setEdges, diagramId, fitView]) + }, [nodes, edges, pushHistory, setNodes, setEdges, diagramId, fitView]) const getExistingBounds = useCallback(() => { const currentNodes = getNodes() @@ -514,6 +687,42 @@ function DiagramEditorInner() { } }, [nodes, name]) + const handleExportSvg = useCallback(async () => { + if (nodes.length === 0) { + toast.warning('Add some devices to the diagram before exporting') + return + } + try { + const { toSvg } = await import('html-to-image') + const IMAGE_WIDTH = 1920 + const IMAGE_HEIGHT = 1080 + const bounds = getNodesBounds(nodes) + const viewport = getViewportForBounds(bounds, IMAGE_WIDTH, IMAGE_HEIGHT, 0.5, 2, 0.15) + const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null + if (!flowEl) { + toast.error('Could not find canvas to export') + return + } + const dataUrl = await toSvg(flowEl, { + backgroundColor: '#16181f', + width: IMAGE_WIDTH, + height: IMAGE_HEIGHT, + style: { + width: `${IMAGE_WIDTH}px`, + height: `${IMAGE_HEIGHT}px`, + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, + transformOrigin: 'top left', + }, + }) + const a = document.createElement('a') + a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.svg` + a.href = dataUrl + a.click() + } catch { + toast.error('SVG export failed') + } + }, [nodes, name]) + const handleExportPdf = useCallback(() => { window.print() }, []) @@ -534,6 +743,54 @@ function DiagramEditorInner() { } }, [diagramId, name]) + const handleExportDrawio = useCallback(() => { + if (nodes.length === 0) { + toast.warning('Add some devices to the diagram before exporting') + return + } + const xml = exportToDrawio(getNodes(), edges) + const blob = new Blob([xml], { type: 'application/xml' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${name.replace(/[^a-zA-Z0-9-_ ]/g, '') || 'diagram'}.drawio` + a.click() + URL.revokeObjectURL(url) + }, [nodes, edges, getNodes, name]) + + const handleImportDrawio = useCallback(() => { + drawioImportRef.current?.click() + }, []) + + const handleDrawioFileChange = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + try { + const text = await file.text() + const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text) + const importPayload = { + schemaVersion: 1 as const, + name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram', + client_name: null, + description: null, + nodes: importedNodes, + edges: importedEdges, + } + const result = await networkDiagramsApi.importJson(importPayload) + const allWarnings = [...warnings, ...result.warnings] + if (allWarnings.length > 0) { + toast.warning(`Imported with ${allWarnings.length} warning(s): ${allWarnings[0]}`) + } else { + toast.success('draw.io file imported successfully') + } + navigate(`/network-diagrams/${result.diagram.id}`) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error' + toast.error(`Import failed: ${msg}`) + } + }, [navigate]) + if (loading) { return (
@@ -551,11 +808,20 @@ function DiagramEditorInner() { isSaving={isSaving} lastSavedAt={lastSavedAt} diagramId={diagramId} - onNameChange={n => { setName(n); setIsDirty(true) }} + onNameChange={(n: string) => { setName(n); setIsDirty(true) }} onSave={handleSave} onExportPng={handleExportPng} + onExportSvg={handleExportSvg} onExportPdf={handleExportPdf} onExportJson={handleExportJson} + onExportDrawio={handleExportDrawio} + onImportDrawio={handleImportDrawio} + onUndo={undo} + onRedo={redo} + canUndo={canUndo} + canRedo={canRedo} + interactionMode={interactionMode} + onModeChange={setInteractionMode} />
@@ -567,6 +833,7 @@ function DiagramEditorInner() { onNodesChange={handleNodesChange} onEdgesChange={handleEdgesChange} onConnect={onConnect} + onReconnect={onReconnect} onNodeSelect={setSelectedNodeId} onEdgeSelect={setSelectedEdgeId} onDrop={onDrop} @@ -576,10 +843,24 @@ function DiagramEditorInner() { onNodeContextMenu={handleNodeContextMenu} onPaneContextMenu={handlePaneContextMenu} onPaneClick={closeContextMenu} + interactionMode={interactionMode} /> + {interactionMode === 'connect' && ( +
+ Connect mode: drag between device handles. Middle-click and drag to pan. +
+ )} {nodes.length === 0 && !loading && ( )} + {/* Keyboard shortcut hint button — above the MiniMap */} +
{nodes.length > 0 && ( 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} />
{contextMenu && ( @@ -626,6 +922,20 @@ function DiagramEditorInner() { }) } onClose={closeContextMenu} + onAlignLeft={diagramCommands.alignLeft} + onAlignRight={diagramCommands.alignRight} + onAlignCenterH={diagramCommands.alignCenterH} + onAlignTop={diagramCommands.alignTop} + onAlignBottom={diagramCommands.alignBottom} + onAlignCenterV={diagramCommands.alignCenterV} + onDistributeH={diagramCommands.distributeHorizontally} + onDistributeV={diagramCommands.distributeVertically} + canAlign={contextMenu.type === 'node' ? diagramCommands.canAlign : false} + canDistribute={contextMenu.type === 'node' ? diagramCommands.canDistribute : false} + onGroupSelection={diagramCommands.groupSelection} + onUngroupSelection={diagramCommands.ungroupSelection} + canGroup={contextMenu?.type === 'node' ? diagramCommands.canGroup : false} + canUngroup={contextMenu?.type === 'node' ? diagramCommands.canUngroup : false} /> )} {pendingDeleteNodeId && ( @@ -647,6 +957,16 @@ function DiagramEditorInner() {
)} + + {showShortcuts && ( + setShowShortcuts(false)} /> + )}
) } diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index 11a71128..e4771800 100644 --- a/frontend/src/pages/NetworkDiagrams/index.tsx +++ b/frontend/src/pages/NetworkDiagrams/index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useNavigate } from 'react-router-dom' -import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react' +import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown, FileJson, FileOutput, ExternalLink, Copy, Archive } from 'lucide-react' import { cn } from '@/lib/utils' import { networkDiagramsApi } from '@/api' import { toast } from '@/lib/toast' @@ -39,7 +39,10 @@ export default function NetworkDiagramsPage() { const [clientSearch, setClientSearch] = useState('') const [menuOpenId, setMenuOpenId] = useState(null) const [confirmArchiveId, setConfirmArchiveId] = useState(null) + const [importMenuOpen, setImportMenuOpen] = useState(false) const clientDropdownRef = useRef(null) + const importMenuRef = useRef(null) + const drawioListImportRef = useRef(null) useEffect(() => { if (!clientDropdownOpen) return @@ -52,6 +55,17 @@ export default function NetworkDiagramsPage() { return () => document.removeEventListener('mousedown', handleClick) }, [clientDropdownOpen]) + useEffect(() => { + if (!importMenuOpen) return + const handleClick = (e: MouseEvent) => { + if (importMenuRef.current && !importMenuRef.current.contains(e.target as Node)) { + setImportMenuOpen(false) + } + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, [importMenuOpen]) + const loadDiagrams = useCallback(async () => { try { const params: Record = {} @@ -129,6 +143,35 @@ export default function NetworkDiagramsPage() { input.click() }, [navigate]) + const handleListDrawioImport = useCallback(async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + e.target.value = '' + try { + const { parseDrawioXml } = await import('@/lib/drawio-import') + const text = await file.text() + const { nodes: importedNodes, edges: importedEdges, warnings } = parseDrawioXml(text) + const result = await networkDiagramsApi.importJson({ + schemaVersion: 1, + name: file.name.replace(/\.drawio$/i, '') || 'Imported Diagram', + client_name: null, + description: null, + nodes: importedNodes, + edges: importedEdges, + }) + const allWarnings = [...warnings, ...result.warnings] + if (allWarnings.length > 0) { + toast.warning(`Imported with ${allWarnings.length} warning(s)`) + } else { + toast.success('Imported successfully') + } + navigate(`/network-diagrams/${result.diagram.id}`) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unknown error' + toast.error(`Import failed: ${msg}`) + } + }, [navigate]) + const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) } @@ -141,13 +184,48 @@ export default function NetworkDiagramsPage() {

Visual network topology documentation for your clients

- + {/* Single "Import" dropdown replacing two separate buttons */} +
+ + {importMenuOpen && ( +
+ + +
+ )} +
+ +
+
+ {/* Left: mini topology preview */} +
+ {/* Dot grid background */} + + + + + + + + + {/* Static topology SVG */} + + {/* Edges */} + + + + + + + + {/* Firewall node */} + + + + + Firewall + {/* Switch node */} + + + + + + Core Switch + {/* Router node */} + + + + + Router + {/* Server farm */} + + + + + + Servers + {/* Leaf nodes */} + + PC × 12 + + AP × 4 + + NAS + + VM × 6 + +
+ + {/* Right: value prop + CTA */} +
+
+ + Network Maps +
+

+ Document every client's infrastructure — once +

+

+ 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. +

+
    +
  • +
    + AI topology generation from natural language +
  • +
  • +
    + Export to PNG, SVG, PDF, or draw.io +
  • +
  • +
    + Shared across your whole team instantly +
  • +
+
+ + +
+
+
)} @@ -260,6 +433,29 @@ export default function NetworkDiagramsPage() { {d.description && (

{d.description}

)} + {/* Thumbnail preview */} + {d.thumbnail_url ? ( +
+ {d.name} { (e.target as HTMLImageElement).style.display = 'none' }} + /> +
+ ) : ( +
+ + + + + + + + + +
+ )} {d.node_count > 0 && (
@@ -294,20 +490,24 @@ export default function NetworkDiagramsPage() { <> +
diff --git a/frontend/src/types/network-diagram.ts b/frontend/src/types/network-diagram.ts index 878984b7..600006f7 100644 --- a/frontend/src/types/network-diagram.ts +++ b/frontend/src/types/network-diagram.ts @@ -18,6 +18,7 @@ export interface DiagramNode { properties: DeviceProperties nodeType?: string style?: { width?: number; height?: number } | null + parentId?: string | null } export interface DiagramEdge { @@ -28,7 +29,7 @@ export interface DiagramEdge { connectionType: string speed: string | null notes: string | null - routing?: string | null + routing?: 'curved' | 'step' | 'orthogonal' | null } export interface DeviceTypeResponse { @@ -72,6 +73,7 @@ export interface NetworkDiagramListItem { description: string | null node_count: number category_counts: Record + thumbnail_url?: string | null created_by: string | null created_at: string updated_at: string @@ -123,6 +125,12 @@ export interface DiagramImportResponse { warnings: string[] } +export interface GroupNodeData { + label: string + groupType: 'subnet' | 'vlan' | 'site' | 'dmz' | 'custom' + [key: string]: unknown +} + export interface DiagramExportResponse { schemaVersion: number name: string