From 760e0f77f80c3c67c7f102d57e96af56a2dc6d6d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 18:16:54 +0000 Subject: [PATCH 01/31] docs: add network diagram draw.io-style implementation plan Co-Authored-By: Claude Sonnet 4.6 --- ...ork-diagram-drawio-style-implementation.md | 757 ++++++++++++++++++ 1 file changed, 757 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-network-diagram-drawio-style-implementation.md 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. -- 2.49.1 From b9547e6ce15c66da7cb107098234c4dbd56be4d2 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 18:23:23 +0000 Subject: [PATCH 02/31] docs: add network diagrams Phase 2 implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-13-network-diagrams-phase2.md | 1320 +++++++++++++++++ 1 file changed, 1320 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-13-network-diagrams-phase2.md 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. -- 2.49.1 From 662df2907d56e3b232957b3dbd5e82196113935c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:01:21 +0000 Subject: [PATCH 03/31] feat(network): add undo/redo snapshot history stack to DiagramEditor Co-Authored-By: Claude Sonnet 4.6 --- .../pages/NetworkDiagrams/DiagramEditor.tsx | 138 +++++++++++++++--- 1 file changed, 114 insertions(+), 24 deletions(-) diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index c20b4507..0286ff36 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -70,6 +70,45 @@ function DiagramEditorInner() { 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 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 + } + }, []) + + 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 + const { copyNodes, pasteNodes, @@ -161,6 +200,36 @@ 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 }, + 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 +238,7 @@ function DiagramEditorInner() { } })() return () => { cancelled = true } - }, [id, navigate, setNodes, setEdges]) + }, [id, navigate, setNodes, setEdges, pushHistory]) const serializeNodes = useCallback((): DiagramNode[] => { return getNodes().map(n => { @@ -254,13 +323,14 @@ 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 onDragOver = useCallback((event: React.DragEvent) => { event.preventDefault() @@ -334,6 +404,7 @@ function DiagramEditorInner() { } satisfies DeviceProperties, } satisfies DeviceNodeData, } + pushHistory(nodes, edges) setNodes(nds => [...nds, newNode]) setIsDirty(true) return @@ -353,20 +424,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 +456,49 @@ 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) => { + pushHistory(nodes, edges) 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) }) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleSendToBack = useCallback((nodeId: string) => { + pushHistory(nodes, edges) 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) }) setIsDirty(true) - }, [setNodes]) + }, [nodes, edges, pushHistory, setNodes]) const handleAIGenerate = useCallback((result: AIGenerateResponse, mode: 'replace' | 'merge') => { const newNodes: Node[] = result.nodes.map(n => ({ @@ -442,6 +521,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 +543,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() @@ -544,19 +624,29 @@ function DiagramEditorInner() { return (
- { setName(n); setIsDirty(true) }} - onSave={handleSave} - onExportPng={handleExportPng} - onExportPdf={handleExportPdf} - onExportJson={handleExportJson} - /> + {(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const DiagramHeaderAny = DiagramHeader as any + return ( + { setName(n); setIsDirty(true) }} + onSave={handleSave} + onExportPng={handleExportPng} + onExportPdf={handleExportPdf} + onExportJson={handleExportJson} + onUndo={undo} + onRedo={redo} + canUndo={canUndo} + canRedo={canRedo} + /> + ) + })()}
-- 2.49.1 From b9c9bb548dc0e7d6fdb939dfb568fccab231895c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:03:35 +0000 Subject: [PATCH 04/31] fix(network): force re-render on undo/redo so canUndo/canRedo stay accurate Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 0286ff36..b08bafa2 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useRef } from 'react' +import { useState, useCallback, useEffect, useRef, useReducer } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { ReactFlowProvider, @@ -74,6 +74,7 @@ function DiagramEditorInner() { 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) @@ -86,7 +87,8 @@ function DiagramEditorInner() { } else { historyIndex.current += 1 } - }, []) + forceHistoryUpdate() + }, [forceHistoryUpdate]) const undo = useCallback(() => { if (historyIndex.current <= 0) return @@ -95,7 +97,8 @@ function DiagramEditorInner() { setNodes(snapshot.nodes) setEdges(snapshot.edges) setIsDirty(true) - }, [setNodes, setEdges]) + forceHistoryUpdate() + }, [setNodes, setEdges, forceHistoryUpdate]) const redo = useCallback(() => { if (historyIndex.current >= historyStack.current.length - 1) return @@ -104,7 +107,8 @@ function DiagramEditorInner() { setNodes(snapshot.nodes) setEdges(snapshot.edges) setIsDirty(true) - }, [setNodes, setEdges]) + forceHistoryUpdate() + }, [setNodes, setEdges, forceHistoryUpdate]) const canUndo = historyIndex.current > 0 const canRedo = historyIndex.current < historyStack.current.length - 1 -- 2.49.1 From a392d241010cfebf34cf13b429277b7024ca6d3f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:04:58 +0000 Subject: [PATCH 05/31] feat(network): add undo/redo buttons to DiagramHeader Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/DiagramHeader.tsx | 31 +++++++++++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 40 ++++++++----------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index 000a8608..1b6916ca 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -1,6 +1,6 @@ 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, Image, FileText, Undo2, Redo2 } from 'lucide-react' interface DiagramHeaderProps { name: string @@ -14,6 +14,10 @@ interface DiagramHeaderProps { onExportPng: () => void onExportPdf: () => void onExportJson: () => void + onUndo: () => void + onRedo: () => void + canUndo: boolean + canRedo: boolean } export function DiagramHeader({ @@ -28,6 +32,10 @@ export function DiagramHeader({ onExportPng, onExportPdf, onExportJson, + onUndo, + onRedo, + canUndo, + canRedo, }: DiagramHeaderProps) { const navigate = useNavigate() const [editing, setEditing] = useState(false) @@ -88,6 +96,27 @@ export function DiagramHeader({
+
+ + +
+ +
+ {editing ? ( - {(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const DiagramHeaderAny = DiagramHeader as any - return ( - { setName(n); setIsDirty(true) }} - onSave={handleSave} - onExportPng={handleExportPng} - onExportPdf={handleExportPdf} - onExportJson={handleExportJson} - onUndo={undo} - onRedo={redo} - canUndo={canUndo} - canRedo={canRedo} - /> - ) - })()} + { setName(n); setIsDirty(true) }} + onSave={handleSave} + onExportPng={handleExportPng} + onExportPdf={handleExportPdf} + onExportJson={handleExportJson} + onUndo={undo} + onRedo={redo} + canUndo={canUndo} + canRedo={canRedo} + />
-- 2.49.1 From 02c19a758015a7be55a41c058d588ac649f33692 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:06:33 +0000 Subject: [PATCH 06/31] feat(network): add undo/redo shortcuts (Ctrl+Z/Y) and arrow key nudging Co-Authored-By: Claude Sonnet 4.6 --- .../network/hooks/useCanvasShortcuts.ts | 33 ++++++++++++++++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 14 ++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/network/hooks/useCanvasShortcuts.ts b/frontend/src/components/network/hooks/useCanvasShortcuts.ts index 2f04ff97..2aeb1645 100644 --- a/frontend/src/components/network/hooks/useCanvasShortcuts.ts +++ b/frontend/src/components/network/hooks/useCanvasShortcuts.ts @@ -33,6 +33,9 @@ export function useCanvasShortcuts({ setEdges, setIsDirty, canvasRef, + onUndo, + onRedo, + onNudge, }: { nodes: Node[] edges: Edge[] @@ -40,6 +43,9 @@ export function useCanvasShortcuts({ setEdges: React.Dispatch> setIsDirty: (dirty: boolean) => void canvasRef: React.RefObject + onUndo: () => void + onRedo: () => void + onNudge: (dx: number, dy: number) => void }) { const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow() const clipboardRef = useRef(null) @@ -211,6 +217,31 @@ 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 + } + if (ctrl && e.key === 'c') { e.preventDefault() copyNodes() @@ -237,7 +268,7 @@ export function useCanvasShortcuts({ 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]) return { copyNodes, diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 2e99d718..24a93f9f 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -113,6 +113,17 @@ function DiagramEditorInner() { const canUndo = historyIndex.current > 0 const canRedo = historyIndex.current < historyStack.current.length - 1 + 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, @@ -127,6 +138,9 @@ function DiagramEditorInner() { setEdges, setIsDirty: (v: boolean) => setIsDirty(v), canvasRef, + onUndo: undo, + onRedo: redo, + onNudge, }) const handleNodesChange: typeof onNodesChange = useCallback((changes) => { -- 2.49.1 From bdaea68dd35a65ac7101e1c710f9b3eab3f3ced7 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:08:37 +0000 Subject: [PATCH 07/31] =?UTF-8?q?feat(network):=20add=20useDiagramCommands?= =?UTF-8?q?=20=E2=80=94=20alignment=20and=20distribution=20command=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../network/hooks/useDiagramCommands.ts | 138 ++++++++++++++++++ .../pages/NetworkDiagrams/DiagramEditor.tsx | 8 + 2 files changed, 146 insertions(+) create mode 100644 frontend/src/components/network/hooks/useDiagramCommands.ts diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts new file mode 100644 index 00000000..4707932c --- /dev/null +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -0,0 +1,138 @@ +import { useCallback } from 'react' +import { 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 + + return { + alignLeft, + alignRight, + alignCenterH, + alignTop, + alignBottom, + alignCenterV, + distributeHorizontally, + distributeVertically, + canAlign, + canDistribute, + selectedNodes, + } +} diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 24a93f9f..60e26ee3 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -17,6 +17,7 @@ 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 { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands' import { DiagramHeader } from '@/components/network/DiagramHeader' import { DeviceToolbar } from '@/components/network/panels/DeviceToolbar' import { PropertiesPanel } from '@/components/network/panels/PropertiesPanel' @@ -113,6 +114,13 @@ function DiagramEditorInner() { 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 -- 2.49.1 From f90e2c956f99ac1f0cd1193a7aaf2dd299dfd5f1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:09:32 +0000 Subject: [PATCH 08/31] feat(network): add align/distribute/group sections to context menu Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/ContextMenu.tsx | 94 ++++++++++++++++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 14 +++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/network/ContextMenu.tsx b/frontend/src/components/network/ContextMenu.tsx index 2ea4b1ad..ca4e4151 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, 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/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 60e26ee3..1da1cd7a 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -736,6 +736,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={() => {}} + onUngroupSelection={() => {}} + canGroup={false} + canUngroup={false} /> )} {pendingDeleteNodeId && ( -- 2.49.1 From 764db7906087e19827133b0833aec7307f62939a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:11:12 +0000 Subject: [PATCH 09/31] feat(network): add alignment toolbar to PropertiesPanel for multi-select Co-Authored-By: Claude Sonnet 4.6 --- .../network/hooks/useDiagramCommands.ts | 2 +- .../network/panels/PropertiesPanel.tsx | 87 ++++++++++++++++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 11 +++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts index 4707932c..6a361d73 100644 --- a/frontend/src/components/network/hooks/useDiagramCommands.ts +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react' -import { Node, Edge } from '@xyflow/react' +import type { Node, Edge } from '@xyflow/react' interface UseDiagramCommandsParams { nodes: Node[] diff --git a/frontend/src/components/network/panels/PropertiesPanel.tsx b/frontend/src/components/network/panels/PropertiesPanel.tsx index 99b1b43a..b92c6708 100644 --- a/frontend/src/components/network/panels/PropertiesPanel.tsx +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -1,5 +1,10 @@ import { useCallback, useState, useEffect } from 'react' -import { Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack } from 'lucide-react' +import { + Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack, + AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, + AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, + AlignHorizontalSpaceAround, AlignVerticalSpaceAround, +} from 'lucide-react' import { cn } from '@/lib/utils' import type { DeviceProperties, DiagramEdge } from '@/types' import type { Node, Edge } from '@xyflow/react' @@ -15,6 +20,17 @@ 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 } type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown' @@ -78,6 +94,17 @@ export function PropertiesPanel({ onSendToBack, onDeleteNode, onDeleteEdge, + selectedNodeCount, + onAlignLeft, + onAlignRight, + onAlignCenterH, + onAlignTop, + onAlignBottom, + onAlignCenterV, + onDistributeH, + onDistributeV, + canAlign, + canDistribute, }: PropertiesPanelProps) { const [deleteConfirm, setDeleteConfirm] = useState(false) @@ -98,6 +125,64 @@ 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
+
+ + +
+
+ )} +
+
+ ) + } + if (!selectedNode && !selectedEdge) { return (
diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 1da1cd7a..7b04fcff 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -709,6 +709,17 @@ function DiagramEditorInner() { onSendToBack={handleSendToBack} onDeleteNode={handleDeleteNode} onDeleteEdge={handleDeleteEdge} + selectedNodeCount={nodes.filter(n => n.selected).length} + onAlignLeft={diagramCommands.alignLeft} + onAlignRight={diagramCommands.alignRight} + onAlignCenterH={diagramCommands.alignCenterH} + onAlignTop={diagramCommands.alignTop} + onAlignBottom={diagramCommands.alignBottom} + onAlignCenterV={diagramCommands.alignCenterV} + onDistributeH={diagramCommands.distributeHorizontally} + onDistributeV={diagramCommands.distributeVertically} + canAlign={diagramCommands.canAlign} + canDistribute={diagramCommands.canDistribute} />
{contextMenu && ( -- 2.49.1 From a4512dcf90a115429665f8886008db1aba4802ad Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:13:03 +0000 Subject: [PATCH 10/31] feat(network): add GroupNode component with resize, inline label, and group type colors Co-Authored-By: Claude Sonnet 4.6 --- .../components/network/nodes/GroupNode.tsx | 86 +++++++++++++++++++ .../src/components/network/nodes/nodeTypes.ts | 2 +- frontend/src/types/network-diagram.ts | 6 ++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/network/nodes/GroupNode.tsx diff --git a/frontend/src/components/network/nodes/GroupNode.tsx b/frontend/src/components/network/nodes/GroupNode.tsx new file mode 100644 index 00000000..69f04be2 --- /dev/null +++ b/frontend/src/components/network/nodes/GroupNode.tsx @@ -0,0 +1,86 @@ +import { memo, useState, useRef, useEffect } from 'react' +import { NodeProps, NodeResizer, useReactFlow } 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="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} + + )} +
+
+ + ) +} + +GroupNodeComponent.displayName = 'GroupNode' + +export const GroupNode = memo(GroupNodeComponent) +export default GroupNode 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/types/network-diagram.ts b/frontend/src/types/network-diagram.ts index 878984b7..55962640 100644 --- a/frontend/src/types/network-diagram.ts +++ b/frontend/src/types/network-diagram.ts @@ -123,6 +123,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 -- 2.49.1 From b7b0d41f92766f592c324854845417715175ef41 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:14:26 +0000 Subject: [PATCH 11/31] feat(network): add group/ungroup commands with bounding box calculation Co-Authored-By: Claude Sonnet 4.6 --- .../network/hooks/useDiagramCommands.ts | 68 +++++++++++++++++++ .../components/network/nodes/GroupNode.tsx | 2 +- .../pages/NetworkDiagrams/DiagramEditor.tsx | 8 +-- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts index 6a361d73..4d57e08b 100644 --- a/frontend/src/components/network/hooks/useDiagramCommands.ts +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -122,6 +122,70 @@ export function useDiagramCommands({ const canAlign = selectedNodes.length >= 2 const canDistribute = selectedNodes.length >= 3 + // ── Grouping ─────────────────────────────────────────────────────────── + const groupSelection = useCallback(() => { + 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: 'Group', groupType: 'custom' }, + 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, @@ -134,5 +198,9 @@ export function useDiagramCommands({ canAlign, canDistribute, selectedNodes, + groupSelection, + ungroupSelection, + canGroup, + canUngroup, } } diff --git a/frontend/src/components/network/nodes/GroupNode.tsx b/frontend/src/components/network/nodes/GroupNode.tsx index 69f04be2..c65feac7 100644 --- a/frontend/src/components/network/nodes/GroupNode.tsx +++ b/frontend/src/components/network/nodes/GroupNode.tsx @@ -1,5 +1,5 @@ import { memo, useState, useRef, useEffect } from 'react' -import { NodeProps, NodeResizer, useReactFlow } from '@xyflow/react' +import { NodeResizer, useReactFlow, type NodeProps } from '@xyflow/react' import type { GroupNodeData } from '@/types/network-diagram' const GROUP_COLORS: Record = { diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 7b04fcff..e10629fc 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -757,10 +757,10 @@ function DiagramEditorInner() { onDistributeV={diagramCommands.distributeVertically} canAlign={contextMenu.type === 'node' ? diagramCommands.canAlign : false} canDistribute={contextMenu.type === 'node' ? diagramCommands.canDistribute : false} - onGroupSelection={() => {}} - onUngroupSelection={() => {}} - canGroup={false} - canUngroup={false} + onGroupSelection={diagramCommands.groupSelection} + onUngroupSelection={diagramCommands.ungroupSelection} + canGroup={contextMenu?.type === 'node' ? diagramCommands.canGroup : false} + canUngroup={contextMenu?.type === 'node' ? diagramCommands.canUngroup : false} /> )} {pendingDeleteNodeId && ( -- 2.49.1 From 4529955f7d3bc651e46f5aa41f974a0e152b6062 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:17:33 +0000 Subject: [PATCH 12/31] feat(network): add orthogonal edge routing option Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/network/edges/ConnectionEdge.tsx | 1 + frontend/src/components/network/panels/PropertiesPanel.tsx | 3 ++- frontend/src/types/network-diagram.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/network/edges/ConnectionEdge.tsx b/frontend/src/components/network/edges/ConnectionEdge.tsx index e6c30b6f..5b769eec 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) } diff --git a/frontend/src/components/network/panels/PropertiesPanel.tsx b/frontend/src/components/network/panels/PropertiesPanel.tsx index b92c6708..d319a4da 100644 --- a/frontend/src/components/network/panels/PropertiesPanel.tsx +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useState, useEffect } from 'react' import { - Trash2, Minus, Spline, GitBranch, BringToFront, SendToBack, + Trash2, Minus, Spline, GitBranch, CornerUpRight, BringToFront, SendToBack, AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignHorizontalSpaceAround, AlignVerticalSpaceAround, @@ -264,6 +264,7 @@ export function PropertiesPanel({ { value: null, icon: Minus, label: 'Straight' }, { value: 'curved', icon: Spline, label: 'Curved' }, { value: 'step', icon: GitBranch, label: 'Step' }, + { value: 'orthogonal', icon: CornerUpRight, label: 'Ortho' }, ] as const).map(({ value, icon: Icon, label }) => { const routing = (edgeData.routing as string | null | undefined) ?? null const active = routing === value diff --git a/frontend/src/types/network-diagram.ts b/frontend/src/types/network-diagram.ts index 55962640..aeff51a4 100644 --- a/frontend/src/types/network-diagram.ts +++ b/frontend/src/types/network-diagram.ts @@ -28,7 +28,7 @@ export interface DiagramEdge { connectionType: string speed: string | null notes: string | null - routing?: string | null + routing?: 'curved' | 'step' | 'orthogonal' | null } export interface DeviceTypeResponse { -- 2.49.1 From 9786c6b1fb1607bde10667d12fe18bd74baebde0 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:17:41 +0000 Subject: [PATCH 13/31] feat(network): add inline label editing on DeviceNode (double-click) Co-Authored-By: Claude Sonnet 4.6 --- .../components/network/nodes/DeviceNode.tsx | 62 ++++++++++++++++--- 1 file changed, 55 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/network/nodes/DeviceNode.tsx b/frontend/src/components/network/nodes/DeviceNode.tsx index 30b9faf6..d0171849 100644 --- a/frontend/src/components/network/nodes/DeviceNode.tsx +++ b/frontend/src/components/network/nodes/DeviceNode.tsx @@ -1,6 +1,6 @@ -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' @@ -29,7 +29,7 @@ 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 status = (nodeData.properties?.status || 'unknown') as NodeStatus @@ -47,6 +47,23 @@ function DeviceNodeComponent({ data, selected, width, height }: NodeProps) { // IP font: 9px at default, clamped to [8, 16] const ipPx = Math.max(8, Math.min(16, Math.round(scale * 9))) + 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 return ( @@ -67,9 +84,40 @@ 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} + + )} {ip && ( -- 2.49.1 From f2c3bd7a9b36ec3f4fbd6350321d168f32af7665 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 20:17:44 +0000 Subject: [PATCH 14/31] fix(network): normalize z-order to 1..N after bring-to-front/send-to-back Co-Authored-By: Claude Sonnet 4.6 --- .../pages/NetworkDiagrams/DiagramEditor.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index e10629fc..635be4fe 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -28,6 +28,11 @@ import { toast } from '@/lib/toast' 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 } @@ -307,7 +312,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]) @@ -510,19 +515,20 @@ function DiagramEditorInner() { const handleBringToFront = useCallback((nodeId: string) => { pushHistory(nodes, edges) - 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) + 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) }, [nodes, edges, pushHistory, setNodes]) const handleSendToBack = useCallback((nodeId: string) => { pushHistory(nodes, edges) - 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) - }) + setNodes(prev => normalizeZOrder( + prev.map(n => n.id === nodeId ? { ...n, zIndex: 0 } : n) + )) setIsDirty(true) }, [nodes, edges, pushHistory, setNodes]) -- 2.49.1 From e41d7bd9604181cace6ad7e1e105e27525a3de9c Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 21:27:58 +0000 Subject: [PATCH 15/31] fix(network): align resize border with node visual boundary NodeResizer handles positioned at RF wrapper size, but NodeTooltip and NodeStatusIndicator wrappers had no size constraints, causing BaseNode (w-full h-full) to shrink to content size instead of filling the wrapper. Add w-full h-full to NodeTooltip, NodeTooltipTrigger, and NodeStatusIndicator so the full height chain is maintained. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/ui/node-status-indicator.tsx | 2 +- frontend/src/components/network/ui/node-tooltip.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/network/ui/node-status-indicator.tsx b/frontend/src/components/network/ui/node-status-indicator.tsx index 96032bce..fbad6834 100644 --- a/frontend/src/components/network/ui/node-status-indicator.tsx +++ b/frontend/src/components/network/ui/node-status-indicator.tsx @@ -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) -- 2.49.1 From 4a12c9b37d14ac1d032cd0c12a86936309993067 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Mon, 13 Apr 2026 23:49:26 +0000 Subject: [PATCH 16/31] fix(network): persist group node type, size, and child parentId on save/load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend DiagramNode schema was missing nodeType, style, and parentId fields — Pydantic stripped them on save, so group nodes lost their identity on reload and re-appeared as small device icons. - Backend: add nodeType, style (NodeStyle), parentId to DiagramNode schema - Frontend: serialize parentId for device nodes inside groups - Frontend: restore parentId + extent:'parent' on both deserializer paths (setNodes + history init) - Frontend: add parentId to DiagramNode interface Co-Authored-By: Claude Sonnet 4.6 --- backend/app/schemas/network_diagram.py | 8 ++++++++ frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx | 3 +++ frontend/src/types/network-diagram.ts | 1 + 3 files changed, 12 insertions(+) diff --git a/backend/app/schemas/network_diagram.py b/backend/app/schemas/network_diagram.py index e31d5283..8b85a6f2 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): diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 635be4fe..749883d5 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -207,6 +207,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, @@ -247,6 +248,7 @@ function DiagramEditorInner() { 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, } }) @@ -297,6 +299,7 @@ function DiagramEditorInner() { position: n.position, properties: data.properties, style: { width: dw, height: dh }, + ...(n.parentId ? { parentId: n.parentId } : {}), } }) }, [getNodes]) diff --git a/frontend/src/types/network-diagram.ts b/frontend/src/types/network-diagram.ts index aeff51a4..0ce62a3f 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 { -- 2.49.1 From 684fb07e4711c373c66869a86a736bb103ed861a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 00:38:51 +0000 Subject: [PATCH 17/31] feat(network): add pointer/hand mode toggle to diagram toolbar - Header shows MousePointer2 (select) and Hand (pan) toggle buttons - Select mode: drag on canvas draws a selection box (selectionOnDrag) - Pan mode: drag on canvas pans the viewport (panOnDrag) - Space held in either mode temporarily switches to pan (panActivationKeyCode) - Keyboard shortcuts: V = select mode, H = pan mode - Cursor changes to grab/grabbing in pan mode Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/DiagramHeader.tsx | 39 ++++++++++++++++++- .../src/components/network/NetworkCanvas.tsx | 8 +++- .../network/hooks/useCanvasShortcuts.ts | 14 ++++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 8 +++- 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index 1b6916ca..f4a790a4 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, Undo2, Redo2 } from 'lucide-react' +import { ChevronLeft, Save, Download, FileJson, Image, FileText, Undo2, Redo2, MousePointer2, Hand } from 'lucide-react' +import { cn } from '@/lib/utils' + +export type InteractionMode = 'select' | 'pan' interface DiagramHeaderProps { name: string @@ -18,6 +21,8 @@ interface DiagramHeaderProps { onRedo: () => void canUndo: boolean canRedo: boolean + interactionMode: InteractionMode + onModeChange: (mode: InteractionMode) => void } export function DiagramHeader({ @@ -36,6 +41,8 @@ export function DiagramHeader({ onRedo, canUndo, canRedo, + interactionMode, + onModeChange, }: DiagramHeaderProps) { const navigate = useNavigate() const [editing, setEditing] = useState(false) @@ -117,6 +124,36 @@ export function DiagramHeader({
+ {/* Interaction mode toggle */} +
+ + +
+ +
+ {editing ? ( void onPaneContextMenu?: (event: MouseEvent | React.MouseEvent) => void onPaneClick?: () => void + interactionMode?: InteractionMode } export function NetworkCanvas({ @@ -48,6 +50,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) { @@ -93,10 +96,13 @@ export function NetworkCanvas({ defaultEdgeOptions={{ type: 'connection' }} deleteKeyCode={['Backspace', 'Delete']} multiSelectionKeyCode="Shift" + panOnDrag={interactionMode === 'pan'} + selectionOnDrag={interactionMode === 'select'} + panActivationKeyCode="Space" snapToGrid={true} snapGrid={[20, 20]} fitView - className="bg-page" + className={interactionMode === 'pan' ? 'bg-page cursor-grab active:cursor-grabbing' : 'bg-page'} > diff --git a/frontend/src/components/network/hooks/useCanvasShortcuts.ts b/frontend/src/components/network/hooks/useCanvasShortcuts.ts index 2aeb1645..955ce0be 100644 --- a/frontend/src/components/network/hooks/useCanvasShortcuts.ts +++ b/frontend/src/components/network/hooks/useCanvasShortcuts.ts @@ -36,6 +36,7 @@ export function useCanvasShortcuts({ onUndo, onRedo, onNudge, + onSetMode, }: { nodes: Node[] edges: Edge[] @@ -46,6 +47,7 @@ export function useCanvasShortcuts({ onUndo: () => void onRedo: () => void onNudge: (dx: number, dy: number) => void + onSetMode: (mode: 'select' | 'pan') => void }) { const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow() const clipboardRef = useRef(null) @@ -242,6 +244,16 @@ export function useCanvasShortcuts({ return } + // Mode shortcuts: V = select, H = pan + if (!ctrl && e.key === 'v') { + onSetMode('select') + return + } + if (!ctrl && e.key === 'h') { + onSetMode('pan') + return + } + if (ctrl && e.key === 'c') { e.preventDefault() copyNodes() @@ -268,7 +280,7 @@ export function useCanvasShortcuts({ document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge]) + }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode]) return { copyNodes, diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 749883d5..5259f39a 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -18,7 +18,7 @@ import { NetworkCanvas } from '@/components/network/NetworkCanvas' import { ContextMenu, getNodeMenuActions, getCanvasMenuActions } from '@/components/network/ContextMenu' import { useCanvasShortcuts } from '@/components/network/hooks/useCanvasShortcuts' import { useDiagramCommands } from '@/components/network/hooks/useDiagramCommands' -import { DiagramHeader } from '@/components/network/DiagramHeader' +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' @@ -69,6 +69,8 @@ function DiagramEditorInner() { const [loading, setLoading] = useState(!!id) const [isDragOver, setIsDragOver] = useState(false) + const [interactionMode, setInteractionMode] = useState('select') + const canvasRef = useRef(null) const [contextMenu, setContextMenu] = useState(null) const [pendingDeleteNodeId, setPendingDeleteNodeId] = useState(null) @@ -154,6 +156,7 @@ function DiagramEditorInner() { onUndo: undo, onRedo: redo, onNudge, + onSetMode: setInteractionMode, }) const handleNodesChange: typeof onNodesChange = useCallback((changes) => { @@ -675,6 +678,8 @@ function DiagramEditorInner() { onRedo={redo} canUndo={canUndo} canRedo={canRedo} + interactionMode={interactionMode} + onModeChange={setInteractionMode} />
@@ -695,6 +700,7 @@ function DiagramEditorInner() { onNodeContextMenu={handleNodeContextMenu} onPaneContextMenu={handlePaneContextMenu} onPaneClick={closeContextMenu} + interactionMode={interactionMode} /> {nodes.length === 0 && !loading && ( -- 2.49.1 From dfcad531e2aacf9337f3b8ccfd653f7d7bb026a8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 00:55:34 +0000 Subject: [PATCH 18/31] fix(network): context menu on groups + group/ungroup in properties panel Context menu fix: - Group nodes pass pointer events through to children in React Flow, so right-clicking a group fires onPaneContextMenu instead of onNodeContextMenu - handlePaneContextMenu now checks for selected nodes and shows the node context menu (with align/group options) when any nodes are selected Properties panel multi-select: - Add Group section with type dropdown (Subnet, VLAN, Site, DMZ, Custom) - "Group into [Type]" button creates a group of the chosen type - Ungroup button appears when a group node is in the selection - useDiagramCommands.groupSelection now accepts a groupType param and uses it as the label and color key for the new group node Co-Authored-By: Claude Sonnet 4.6 --- .../network/hooks/useDiagramCommands.ts | 4 +- .../network/panels/PropertiesPanel.tsx | 50 +++++++++++++++++++ .../pages/NetworkDiagrams/DiagramEditor.tsx | 25 ++++++++-- 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/network/hooks/useDiagramCommands.ts b/frontend/src/components/network/hooks/useDiagramCommands.ts index 4d57e08b..0e728ffb 100644 --- a/frontend/src/components/network/hooks/useDiagramCommands.ts +++ b/frontend/src/components/network/hooks/useDiagramCommands.ts @@ -123,7 +123,7 @@ export function useDiagramCommands({ const canDistribute = selectedNodes.length >= 3 // ── Grouping ─────────────────────────────────────────────────────────── - const groupSelection = useCallback(() => { + const groupSelection = useCallback((groupType: string = 'custom') => { if (selectedNodes.length < 2) return pushHistory(nodes, edges) const PADDING = 24 @@ -137,7 +137,7 @@ export function useDiagramCommands({ type: 'group', position: { x: minX, y: minY }, style: { width: maxX - minX, height: maxY - minY }, - data: { label: 'Group', groupType: 'custom' }, + data: { label: groupType.charAt(0).toUpperCase() + groupType.slice(1), groupType }, selected: false, } setNodes(prev => [ diff --git a/frontend/src/components/network/panels/PropertiesPanel.tsx b/frontend/src/components/network/panels/PropertiesPanel.tsx index d319a4da..229da3b7 100644 --- a/frontend/src/components/network/panels/PropertiesPanel.tsx +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -4,6 +4,7 @@ import { AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignHorizontalSpaceAround, AlignVerticalSpaceAround, + BoxSelect, Ungroup, } from 'lucide-react' import { cn } from '@/lib/utils' import type { DeviceProperties, DiagramEdge } from '@/types' @@ -31,6 +32,10 @@ interface PropertiesPanelProps { onDistributeV: () => void canAlign: boolean canDistribute: boolean + canGroup: boolean + canUngroup: boolean + onGroupSelection: (groupType: string) => void + onUngroupSelection: () => void } type NodeStatus = 'online' | 'offline' | 'degraded' | 'unknown' @@ -84,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, @@ -105,8 +118,13 @@ export function PropertiesPanel({ 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 @@ -178,6 +196,38 @@ export function PropertiesPanel({
)} + {(canGroup || canUngroup) && ( +
+
Grouping
+ {canGroup && ( +
+ + +
+ )} + {canUngroup && ( + + )} +
+ )}
) diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 5259f39a..a810cd93 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -399,11 +399,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) @@ -735,6 +746,10 @@ function DiagramEditorInner() { onDistributeV={diagramCommands.distributeVertically} canAlign={diagramCommands.canAlign} canDistribute={diagramCommands.canDistribute} + canGroup={diagramCommands.canGroup} + canUngroup={diagramCommands.canUngroup} + onGroupSelection={diagramCommands.groupSelection} + onUngroupSelection={diagramCommands.ungroupSelection} />
{contextMenu && ( -- 2.49.1 From 05421fc65c65460684aed3f23f8042ae7389d137 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 01:19:19 +0000 Subject: [PATCH 19/31] feat(network): add SVG export Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/DiagramHeader.tsx | 10 ++++- .../pages/NetworkDiagrams/DiagramEditor.tsx | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index f4a790a4..f3f7e830 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { ChevronLeft, Save, Download, FileJson, Image, FileText, Undo2, Redo2, MousePointer2, Hand } from 'lucide-react' +import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand } from 'lucide-react' import { cn } from '@/lib/utils' export type InteractionMode = 'select' | 'pan' @@ -15,6 +15,7 @@ interface DiagramHeaderProps { onNameChange: (name: string) => void onSave: () => void onExportPng: () => void + onExportSvg: () => void onExportPdf: () => void onExportJson: () => void onUndo: () => void @@ -35,6 +36,7 @@ export function DiagramHeader({ onNameChange, onSave, onExportPng, + onExportSvg, onExportPdf, onExportJson, onUndo, @@ -211,6 +213,12 @@ export function DiagramHeader({ > Export PNG + )} +
)}
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/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 0d000f98..abe302f8 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -25,6 +25,7 @@ import { AIAssistPanel } from '@/components/network/panels/AIAssistPanel' import { CanvasEmptyPrompt } from '@/components/network/CanvasEmptyPrompt' import { networkDiagramsApi, deviceTypesApi } from '@/api' import { toast } from '@/lib/toast' +import { exportToDrawio } from '@/lib/drawio-export' import type { DeviceTypeResponse, DeviceProperties, AIGenerateResponse, DiagramEdge, DiagramNode } from '@/types' import type { DeviceNodeData } from '@/components/network/nodes/DeviceNode' @@ -729,6 +730,21 @@ 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]) + if (loading) { return (
@@ -752,6 +768,7 @@ function DiagramEditorInner() { onExportSvg={handleExportSvg} onExportPdf={handleExportPdf} onExportJson={handleExportJson} + onExportDrawio={handleExportDrawio} onUndo={undo} onRedo={redo} canUndo={canUndo} -- 2.49.1 From 91cc9a4170e3f7c478c9b0a65226aa550c4dd9f1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 01:30:22 +0000 Subject: [PATCH 23/31] feat(network): draw.io XML import Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/DiagramHeader.tsx | 12 +- frontend/src/lib/drawio-import.ts | 142 ++++++++++++++++++ .../pages/NetworkDiagrams/DiagramEditor.tsx | 43 ++++++ frontend/src/pages/NetworkDiagrams/index.tsx | 44 ++++++ 4 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/drawio-import.ts diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index ab3d83f8..f4f3b397 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2 } from 'lucide-react' +import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2, Upload } from 'lucide-react' import { cn } from '@/lib/utils' export type InteractionMode = 'select' | 'pan' @@ -19,6 +19,7 @@ interface DiagramHeaderProps { onExportPdf: () => void onExportJson: () => void onExportDrawio: () => void + onImportDrawio: () => void onUndo: () => void onRedo: () => void canUndo: boolean @@ -41,6 +42,7 @@ export function DiagramHeader({ onExportPdf, onExportJson, onExportDrawio, + onImportDrawio, onUndo, onRedo, canUndo, @@ -199,6 +201,14 @@ export function DiagramHeader({ {isSaving ? 'Saving...' : 'Save'} + +
)} +
) } diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index c62483f6..4ef87e27 100644 --- a/frontend/src/pages/NetworkDiagrams/index.tsx +++ b/frontend/src/pages/NetworkDiagrams/index.tsx @@ -40,6 +40,7 @@ export default function NetworkDiagramsPage() { const [menuOpenId, setMenuOpenId] = useState(null) const [confirmArchiveId, setConfirmArchiveId] = useState(null) const clientDropdownRef = useRef(null) + const drawioListImportRef = useRef(null) useEffect(() => { if (!clientDropdownOpen) return @@ -129,6 +130,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' }) } @@ -148,6 +178,20 @@ export default function NetworkDiagramsPage() { Import + + +
+ Asset Style Lab +
+

Two directions for network-map assets

+

+ Same topology, two visual languages. The left mock-up leans closer to infrastructure documentation. + The right mock-up leans toward a cleaner product experience inside the app. +

+
+
+ +
+ + +
+ +
+ The fastest next step is to pick the lane that feels more “ResolutionFlow,” then I can translate that look into the actual editor nodes rather than keeping it as a mock-up. +
+
+ ) +} diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index 4ef87e27..c60cf732 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, Sparkles } from 'lucide-react' import { cn } from '@/lib/utils' import { networkDiagramsApi } from '@/api' import { toast } from '@/lib/toast' @@ -171,6 +171,13 @@ export default function NetworkDiagramsPage() {

Visual network topology documentation for your clients

+ -
- Asset Style Lab -
-

Two directions for network-map assets

-

- Same topology, two visual languages. The left mock-up leans closer to infrastructure documentation. - The right mock-up leans toward a cleaner product experience inside the app. -

-
-
- -
- - -
- -
- The fastest next step is to pick the lane that feels more “ResolutionFlow,” then I can translate that look into the actual editor nodes rather than keeping it as a mock-up. -
-
- ) -} diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index c60cf732..4ef87e27 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, Sparkles } from 'lucide-react' +import { Plus, Search, Network, MoreHorizontal, Upload, ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' import { networkDiagramsApi } from '@/api' import { toast } from '@/lib/toast' @@ -171,13 +171,6 @@ export default function NetworkDiagramsPage() {

Visual network topology documentation for your clients

- +
diff --git a/frontend/src/components/network/NetworkCanvas.tsx b/frontend/src/components/network/NetworkCanvas.tsx index ddd34f80..5dc18919 100644 --- a/frontend/src/components/network/NetworkCanvas.tsx +++ b/frontend/src/components/network/NetworkCanvas.tsx @@ -99,17 +99,22 @@ export function NetworkCanvas({ edgeTypes={edgeTypes} defaultEdgeOptions={{ type: 'connection' }} edgesReconnectable + connectOnClick={interactionMode === 'connect'} reconnectRadius={20} connectionRadius={24} deleteKeyCode={['Backspace', 'Delete']} multiSelectionKeyCode="Shift" - panOnDrag={interactionMode === 'pan'} + panOnDrag={interactionMode === 'pan' ? [0, 1] : [1]} selectionOnDrag={interactionMode === 'select'} panActivationKeyCode="Space" snapToGrid={true} snapGrid={[20, 20]} fitView - className={interactionMode === 'pan' ? 'bg-page cursor-grab active:cursor-grabbing' : 'bg-page'} + className={cn( + 'bg-page', + interactionMode === 'pan' && 'cursor-grab active:cursor-grabbing', + interactionMode === 'connect' && 'rf-connect-mode cursor-crosshair', + )} > diff --git a/frontend/src/components/network/hooks/useCanvasShortcuts.ts b/frontend/src/components/network/hooks/useCanvasShortcuts.ts index 955ce0be..da33bfb8 100644 --- a/frontend/src/components/network/hooks/useCanvasShortcuts.ts +++ b/frontend/src/components/network/hooks/useCanvasShortcuts.ts @@ -47,7 +47,7 @@ export function useCanvasShortcuts({ onUndo: () => void onRedo: () => void onNudge: (dx: number, dy: number) => void - onSetMode: (mode: 'select' | 'pan') => void + onSetMode: (mode: 'select' | 'pan' | 'connect') => void }) { const { getNodes, fitView, screenToFlowPosition, setNodes: rfSetNodes } = useReactFlow() const clipboardRef = useRef(null) @@ -244,7 +244,7 @@ export function useCanvasShortcuts({ return } - // Mode shortcuts: V = select, H = pan + // Mode shortcuts: V = select, H = pan, C = connect if (!ctrl && e.key === 'v') { onSetMode('select') return @@ -253,6 +253,10 @@ export function useCanvasShortcuts({ onSetMode('pan') return } + if (!ctrl && e.key === 'c') { + onSetMode('connect') + return + } if (ctrl && e.key === 'c') { e.preventDefault() diff --git a/frontend/src/components/network/ui/base-handle.tsx b/frontend/src/components/network/ui/base-handle.tsx index 913d8abb..4f57ca45 100644 --- a/frontend/src/components/network/ui/base-handle.tsx +++ b/frontend/src/components/network/ui/base-handle.tsx @@ -11,6 +11,7 @@ export function BaseHandle({ className, children, ...props }: ComponentProps diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 6b2eab67..689a9039 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -842,6 +842,11 @@ function DiagramEditorInner() { onPaneClick={closeContextMenu} interactionMode={interactionMode} /> + {interactionMode === 'connect' && ( +
+ Connect mode: drag between device handles. Middle-click and drag to pan. +
+ )} {nodes.length === 0 && !loading && ( )} -- 2.49.1 From cf9c258f9ebb5b0b88b3b3562ff8b263bf5fb50b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 03:41:21 +0000 Subject: [PATCH 29/31] fix(network): surface connect tool and middle-pan --- frontend/src/components/network/DiagramHeader.tsx | 11 +++++++---- frontend/src/components/network/NetworkCanvas.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index 9cc59332..618fafe7 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -131,42 +131,45 @@ export function DiagramHeader({
{/* Interaction mode toggle */} -
+
diff --git a/frontend/src/components/network/NetworkCanvas.tsx b/frontend/src/components/network/NetworkCanvas.tsx index 5dc18919..35363421 100644 --- a/frontend/src/components/network/NetworkCanvas.tsx +++ b/frontend/src/components/network/NetworkCanvas.tsx @@ -17,6 +17,7 @@ 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[] @@ -81,7 +82,15 @@ export function NetworkCanvas({ }, []) return ( -
+
{ + if (event.button === 1) { + event.preventDefault() + } + }} + > Date: Tue, 14 Apr 2026 04:49:25 +0000 Subject: [PATCH 30/31] fix(network): consolidate import buttons, redesign empty state, add shortcut overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import/Export button in editor header: removed standalone Import button, moved draw.io import into Export/Import dropdown with labelled sections; fixes conceptual trap where Import implied operating on the current diagram - List page: replaced two identical Upload-icon Import buttons with a single dropdown (Import JSON / Import draw.io) with format descriptions - Empty state: replaced icon-in-box with a horizontal card featuring a static SVG topology preview, MSP-specific value prop, and dual CTAs - Keyboard shortcuts: new KeyboardShortcutsOverlay component (4-group grid), triggered by ? key or the ? button pinned to the canvas bottom-right corner; wired into useCanvasShortcuts hook - Fixed Share2 → FileOutput icon for draw.io export (Share2 = send to someone, FileOutput = export file format) Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/DiagramHeader.tsx | 35 ++-- .../network/KeyboardShortcutsOverlay.tsx | 129 +++++++++++++ .../network/hooks/useCanvasShortcuts.ts | 7 +- .../pages/NetworkDiagrams/DiagramEditor.tsx | 14 ++ frontend/src/pages/NetworkDiagrams/index.tsx | 179 +++++++++++++++--- 5 files changed, 321 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/network/KeyboardShortcutsOverlay.tsx diff --git a/frontend/src/components/network/DiagramHeader.tsx b/frontend/src/components/network/DiagramHeader.tsx index 618fafe7..3372aa2f 100644 --- a/frontend/src/components/network/DiagramHeader.tsx +++ b/frontend/src/components/network/DiagramHeader.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { ChevronLeft, Save, Download, FileJson, FileCode, Image, FileText, Undo2, Redo2, MousePointer2, Hand, Share2, Upload, Cable } 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' @@ -19,7 +19,7 @@ interface DiagramHeaderProps { onExportPdf: () => void onExportJson: () => void onExportDrawio: () => void - onImportDrawio: () => void + onImportDrawio: () => void // draw.io import — triggered from Export menu onUndo: () => void onRedo: () => void canUndo: boolean @@ -216,55 +216,56 @@ export function DiagramHeader({ {isSaving ? 'Saving...' : 'Save'} - -
{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/hooks/useCanvasShortcuts.ts b/frontend/src/components/network/hooks/useCanvasShortcuts.ts index da33bfb8..ff997473 100644 --- a/frontend/src/components/network/hooks/useCanvasShortcuts.ts +++ b/frontend/src/components/network/hooks/useCanvasShortcuts.ts @@ -37,6 +37,7 @@ export function useCanvasShortcuts({ onRedo, onNudge, onSetMode, + onToggleShortcuts, }: { nodes: Node[] edges: Edge[] @@ -48,6 +49,7 @@ export function useCanvasShortcuts({ 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) @@ -279,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, onUndo, onRedo, onNudge, onSetMode]) + }, [copyNodes, pasteNodes, duplicateNodes, selectAll, fitView, bringSelectedToFront, sendSelectedToBack, onUndo, onRedo, onNudge, onSetMode, onToggleShortcuts]) return { copyNodes, diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index 689a9039..ed3e1661 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -24,6 +24,7 @@ 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' @@ -73,6 +74,7 @@ function DiagramEditorInner() { const [isDragOver, setIsDragOver] = useState(false) const [interactionMode, setInteractionMode] = useState('select') + const [showShortcuts, setShowShortcuts] = useState(false) const canvasRef = useRef(null) const drawioImportRef = useRef(null) @@ -161,6 +163,7 @@ function DiagramEditorInner() { onRedo: redo, onNudge, onSetMode: setInteractionMode, + onToggleShortcuts: () => setShowShortcuts(v => !v), }) const handleNodesChange: typeof onNodesChange = useCallback((changes) => { @@ -850,6 +853,14 @@ function DiagramEditorInner() { {nodes.length === 0 && !loading && ( )} + {/* Keyboard shortcut hint button — bottom-right corner */} +
{nodes.length > 0 && ( + {showShortcuts && ( + setShowShortcuts(false)} /> + )}
) } diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index 4ef87e27..643a492d 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 } from 'lucide-react' import { cn } from '@/lib/utils' import { networkDiagramsApi } from '@/api' import { toast } from '@/lib/toast' @@ -39,7 +39,9 @@ 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(() => { @@ -53,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 = {} @@ -171,13 +184,41 @@ 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 +
  • +
+
+ + +
+
+
)} -- 2.49.1 From b433b232dc830b8ef3b36a184e58535db39493f8 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 05:35:25 +0000 Subject: [PATCH 31/31] polish(network): visual refinements across node, edge, and panel components - DeviceNode: flat bg-card (no surface gradient), darker icon plate inset, correct text-muted token for category label - GroupNode: label pill gets bg-card/90 background so it reads against canvas - ConnectionEdge: label now has border + bg-card so it doesn't float invisible - BaseHandle: tightened to 12px with accent-toned border - NodeStatusIndicator: glow reduced to 0.15 opacity (design system compliant) - ContextMenu: Ungroup now uses Ungroup icon instead of BoxSelect - DeviceToolbar: group type icons coloured with semantic palette - PropertiesPanel: empty state gets icon tile + cleaner copy hierarchy - DiagramEditor: shortcut ? button repositioned above MiniMap, accent hover - NetworkDiagrams list: card thumbnail placeholder uses dot-grid pattern, card menu gets icons and divider before destructive action Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/network/ContextMenu.tsx | 4 ++-- .../network/edges/ConnectionEdge.tsx | 2 +- .../components/network/nodes/DeviceNode.tsx | 6 ++--- .../components/network/nodes/GroupNode.tsx | 6 ++--- .../network/panels/DeviceToolbar.tsx | 12 +++++----- .../network/panels/PropertiesPanel.tsx | 15 +++++++----- .../src/components/network/ui/base-handle.tsx | 2 +- .../network/ui/node-status-indicator.tsx | 6 ++--- .../pages/NetworkDiagrams/DiagramEditor.tsx | 4 ++-- frontend/src/pages/NetworkDiagrams/index.tsx | 24 ++++++++++++++----- 10 files changed, 48 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/network/ContextMenu.tsx b/frontend/src/components/network/ContextMenu.tsx index ca4e4151..2b76a681 100644 --- a/frontend/src/components/network/ContextMenu.tsx +++ b/frontend/src/components/network/ContextMenu.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { - Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2, BringToFront, SendToBack, + Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Ungroup, Maximize2, BringToFront, SendToBack, AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignHorizontalSpaceAround, AlignVerticalSpaceAround, @@ -168,7 +168,7 @@ export function ContextMenu({ )} {canUngroup && ( )} diff --git a/frontend/src/components/network/edges/ConnectionEdge.tsx b/frontend/src/components/network/edges/ConnectionEdge.tsx index 5b769eec..bc87b1be 100644 --- a/frontend/src/components/network/edges/ConnectionEdge.tsx +++ b/frontend/src/components/network/edges/ConnectionEdge.tsx @@ -54,7 +54,7 @@ function ConnectionEdgeComponent(props: EdgeProps) { {props.label && (
- +
-
+
@@ -135,7 +135,7 @@ function DeviceNodeComponent({ id, data, selected, width, height }: NodeProps) { )} {CATEGORY_LABELS[category] ?? nodeData.deviceType.replace(/-/g, ' ')} diff --git a/frontend/src/components/network/nodes/GroupNode.tsx b/frontend/src/components/network/nodes/GroupNode.tsx index c65feac7..55156a4c 100644 --- a/frontend/src/components/network/nodes/GroupNode.tsx +++ b/frontend/src/components/network/nodes/GroupNode.tsx @@ -51,7 +51,7 @@ const GroupNodeComponent = ({ data, selected, id }: NodeProps) => { boxSizing: 'border-box', }} > -
+
{editing ? ( { 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]" + 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)} > 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 0f870091..26c760eb 100644 --- a/frontend/src/components/network/panels/PropertiesPanel.tsx +++ b/frontend/src/components/network/panels/PropertiesPanel.tsx @@ -4,7 +4,7 @@ import { AlignStartVertical, AlignCenterHorizontal, AlignEndVertical, AlignStartHorizontal, AlignCenterVertical, AlignEndHorizontal, AlignHorizontalSpaceAround, AlignVerticalSpaceAround, - BoxSelect, Ungroup, + BoxSelect, Ungroup, MousePointer, } from 'lucide-react' import { cn } from '@/lib/utils' import type { DeviceProperties, DiagramEdge } from '@/types' @@ -235,12 +235,15 @@ export function PropertiesPanel({ 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.

) diff --git a/frontend/src/components/network/ui/base-handle.tsx b/frontend/src/components/network/ui/base-handle.tsx index 4f57ca45..42cbb062 100644 --- a/frontend/src/components/network/ui/base-handle.tsx +++ b/frontend/src/components/network/ui/base-handle.tsx @@ -9,7 +9,7 @@ export function BaseHandle({ className, children, ...props }: ComponentProps = { } 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: '', } diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index ed3e1661..9b86d82a 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -853,11 +853,11 @@ function DiagramEditorInner() { {nodes.length === 0 && !loading && ( )} - {/* Keyboard shortcut hint button — bottom-right corner */} + {/* Keyboard shortcut hint button — above the MiniMap */} diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index 643a492d..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, FileJson, FileOutput } 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' @@ -444,8 +444,16 @@ export default function NetworkDiagramsPage() { />
) : ( -
- +
+ + + + + + + + +
)} {d.node_count > 0 && ( @@ -482,20 +490,24 @@ export default function NetworkDiagramsPage() { <> +
-- 2.49.1