Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8cadba17b | |||
| 5f7cb1bf11 | |||
| 3fb3bf016b | |||
| ba032a45af | |||
| 68b35a0c30 | |||
| 41cfccbd37 | |||
| 55a842cdad | |||
| 7074c5387b |
@@ -4,9 +4,21 @@ Homelable is a self-hosted infrastructure visualization solution. It provides a
|
|||||||
|
|
||||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
|
|
||||||
|
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
|
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Quick Start — Docker
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class Edge(Base):
|
|||||||
speed: Mapped[str | None] = mapped_column(String)
|
speed: Mapped[str | None] = mapped_column(String)
|
||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: Mapped[str | None] = mapped_column(String)
|
path_style: Mapped[str | None] = mapped_column(String)
|
||||||
|
animated: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_handle: Mapped[str | None] = mapped_column(String)
|
target_handle: Mapped[str | None] = mapped_column(String)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class EdgeSave(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class EdgeBase(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 339 KiB |
+59
-14
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
@@ -16,6 +17,8 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
|||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
@@ -28,7 +31,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore()
|
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
@@ -36,6 +39,8 @@ export default function App() {
|
|||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
@@ -111,6 +116,7 @@ export default function App() {
|
|||||||
speed: e.data?.speed ?? null,
|
speed: e.data?.speed ?? null,
|
||||||
custom_color: e.data?.custom_color ?? null,
|
custom_color: e.data?.custom_color ?? null,
|
||||||
path_style: e.data?.path_style ?? null,
|
path_style: e.data?.path_style ?? null,
|
||||||
|
animated: e.data?.animated ?? false,
|
||||||
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
||||||
// map them back to their canonical source handle ID so reload works correctly.
|
// map them back to their canonical source handle ID so reload works correctly.
|
||||||
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
|
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
|
||||||
@@ -200,19 +206,38 @@ export default function App() {
|
|||||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||||
}, [isAuthenticated, loadCanvas, setTheme])
|
}, [isAuthenticated, loadCanvas, setTheme])
|
||||||
|
|
||||||
// Ctrl+S
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
|
const undoRef = useRef(undo)
|
||||||
|
const redoRef = useRef(redo)
|
||||||
|
const copyRef = useRef(copySelectedNodes)
|
||||||
|
const pasteRef = useRef(pasteNodes)
|
||||||
|
useEffect(() => { undoRef.current = undo }, [undo])
|
||||||
|
useEffect(() => { redoRef.current = redo }, [redo])
|
||||||
|
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
||||||
|
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
e.preventDefault()
|
// Ignore shortcuts when typing in an input/textarea
|
||||||
handleSaveRef.current()
|
const tag = (e.target as HTMLElement).tagName
|
||||||
}
|
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable
|
||||||
|
|
||||||
|
if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||||
|
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||||
|
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
||||||
|
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isProxmox = data.type === 'proxmox'
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
@@ -231,9 +256,10 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
}, [addNode, nodes])
|
}, [addNode, nodes, snapshotHistory])
|
||||||
|
|
||||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
@@ -258,7 +284,7 @@ export default function App() {
|
|||||||
zIndex: data.z_order - 10,
|
zIndex: data.z_order - 10,
|
||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
}, [addNode])
|
}, [addNode, snapshotHistory])
|
||||||
|
|
||||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
@@ -281,9 +307,10 @@ export default function App() {
|
|||||||
|
|
||||||
const handleDeleteGroupRect = useCallback(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
|
snapshotHistory()
|
||||||
deleteNode(editingGroupRectId)
|
deleteNode(editingGroupRectId)
|
||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
|
}, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
const handleEditNode = useCallback((id: string) => {
|
const handleEditNode = useCallback((id: string) => {
|
||||||
setEditNodeId(id)
|
setEditNodeId(id)
|
||||||
@@ -291,6 +318,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
||||||
if (!editNodeId) return
|
if (!editNodeId) return
|
||||||
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||||
@@ -320,7 +348,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEditNodeId(null)
|
setEditNodeId(null)
|
||||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect])
|
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
const handleAutoLayout = useCallback(() => {
|
||||||
const laid = applyDagreLayout(nodes, edges)
|
const laid = applyDagreLayout(nodes, edges)
|
||||||
@@ -328,6 +356,13 @@ export default function App() {
|
|||||||
toast.success('Canvas auto-arranged')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [nodes, edges, loadCanvas])
|
||||||
|
|
||||||
|
const handleExportMd = useCallback(async () => {
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
if (!md) { toast.error('No nodes to export'); return }
|
||||||
|
await navigator.clipboard.writeText(md)
|
||||||
|
toast.success('Markdown table copied to clipboard')
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -345,6 +380,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
||||||
if (!pendingConnection) return
|
if (!pendingConnection) return
|
||||||
|
snapshotHistory()
|
||||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
@@ -359,7 +395,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
}, [pendingConnection, onConnect, nodes, updateNode])
|
}, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
@@ -367,15 +403,17 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
updateEdge(editEdgeId, data)
|
updateEdge(editEdgeId, data)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, updateEdge])
|
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDelete = useCallback(() => {
|
const handleEdgeDelete = useCallback(() => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
deleteEdge(editEdgeId)
|
deleteEdge(editEdgeId)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, deleteEdge])
|
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||||
|
|
||||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||||
@@ -399,10 +437,14 @@ export default function App() {
|
|||||||
onAutoLayout={handleAutoLayout}
|
onAutoLayout={handleAutoLayout}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
onChangeStyle={() => setThemeModalOpen(true)}
|
onChangeStyle={() => setThemeModalOpen(true)}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
onExportMd={handleExportMd}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
|
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||||
</div>
|
</div>
|
||||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -496,6 +538,9 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
|
onNodeDragStart?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
@@ -55,6 +56,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
|
useStore,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
type Edge,
|
type Edge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
@@ -17,9 +18,12 @@ function getVlanColor(vlanId?: number): string {
|
|||||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
|
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||||
|
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||||
|
|
||||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
||||||
@@ -46,9 +50,42 @@ export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePo
|
|||||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animated dot: slightly brighter + thicker than the base edge, travels source→target
|
||||||
|
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||||
|
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} />
|
<BaseEdge id={id} path={edgePath} style={style} />
|
||||||
|
{data?.animated && (
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={dotColor}
|
||||||
|
strokeWidth={dotWidth}
|
||||||
|
strokeDasharray="20 10000"
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{isBidirectional ? (
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
values="-10000;0;-10000"
|
||||||
|
keyTimes="0;0.5;1"
|
||||||
|
dur="20s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
from="-10000"
|
||||||
|
to="0"
|
||||||
|
dur="10s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</path>
|
||||||
|
)}
|
||||||
{data?.label && (
|
{data?.label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
|
const [animated, setAnimated] = useState(initial?.animated ?? false)
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
|
animated: animated || undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,22 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Flow Animation</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAnimated((a) => !a)}
|
||||||
|
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
|
||||||
|
style={{ background: animated ? '#00d4ff' : '#30363d' }}
|
||||||
|
aria-pressed={animated}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||||
|
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useReactFlow } from '@xyflow/react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
interface SearchModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
||||||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
|
n.data.hostname?.toLowerCase().includes(q)
|
||||||
|
).slice(0, 8)
|
||||||
|
|
||||||
|
const handleSelect = useCallback((nodeId: string) => {
|
||||||
|
setSelectedNode(nodeId)
|
||||||
|
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||||
|
onClose()
|
||||||
|
setQuery('')
|
||||||
|
}, [fitView, setSelectedNode, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||||
|
<Search size={16} className="text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search nodes by label, IP, hostname…"
|
||||||
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||||
|
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ul className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{results.map((node) => (
|
||||||
|
<li
|
||||||
|
key={node.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
|
onClick={() => handleSelect(node.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||||
|
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||||
|
{node.data.ip && (
|
||||||
|
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.length > 0 && results.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{
|
||||||
|
group: 'Canvas',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Ctrl', 'S'], description: 'Save canvas' },
|
||||||
|
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
|
||||||
|
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
|
||||||
|
{ keys: ['Ctrl', 'K'], description: 'Search nodes' },
|
||||||
|
{ keys: ['?'], description: 'Show this help' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Nodes',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Ctrl', 'C'], description: 'Copy selected nodes' },
|
||||||
|
{ keys: ['Ctrl', 'V'], description: 'Paste nodes' },
|
||||||
|
{ keys: ['Del'], description: 'Delete selected node/edge' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Navigation',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Scroll'], description: 'Zoom in / out' },
|
||||||
|
{ keys: ['Space', '+', 'Drag'], description: 'Pan canvas' },
|
||||||
|
{ keys: ['Ctrl', 'Shift', 'F'], description: 'Fit view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ShortcutsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutsModal({ open, onClose }: ShortcutsModalProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{SHORTCUTS.map((group) => (
|
||||||
|
<div key={group.group}>
|
||||||
|
<p className="text-xs text-[#00d4ff] font-semibold mb-2 uppercase tracking-wide">
|
||||||
|
{group.group}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div key={item.description} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">{item.description}</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{item.keys.map((k, i) => (
|
||||||
|
k === '+' ? (
|
||||||
|
<span key={i} className="text-xs text-muted-foreground">+</span>
|
||||||
|
) : (
|
||||||
|
<kbd key={k} className="text-[11px] text-foreground border border-border rounded px-1.5 py-0.5 font-mono bg-[#0d1117]">
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ShortcutsModal } from '../ShortcutsModal'
|
||||||
|
|
||||||
|
describe('ShortcutsModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ShortcutsModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders shortcut groups when open', () => {
|
||||||
|
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Keyboard Shortcuts')).toBeDefined()
|
||||||
|
expect(screen.getByText('Canvas')).toBeDefined()
|
||||||
|
expect(screen.getByText('Nodes')).toBeDefined()
|
||||||
|
expect(screen.getByText('Navigation')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows key shortcuts in kbd elements', () => {
|
||||||
|
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||||
|
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when backdrop clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||||
|
fireEvent.click(container.firstChild as HTMLElement)
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when X button clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons[0])
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -76,7 +76,21 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||||||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
||||||
{data.hostname && <DetailRow label="Hostname" value={data.hostname} mono />}
|
{data.hostname && (
|
||||||
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
||||||
|
<a
|
||||||
|
href={`http://${data.hostname}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
|
||||||
|
title={data.hostname}
|
||||||
|
>
|
||||||
|
{data.hostname}
|
||||||
|
<ExternalLink size={10} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Save, LayoutDashboard, Download, Palette } from 'lucide-react'
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -8,24 +8,53 @@ interface ToolbarProps {
|
|||||||
onAutoLayout: () => void
|
onAutoLayout: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
onChangeStyle: () => void
|
onChangeStyle: () => void
|
||||||
|
onUndo: () => void
|
||||||
|
onRedo: () => void
|
||||||
|
onShortcuts: () => void
|
||||||
|
onExportMd: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||||
<Logo size={28} showText={true} />
|
<Logo size={28} showText={true} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={past.length === 0}
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<Undo2 size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={future.length === 0}
|
||||||
|
title="Redo (Ctrl+Y)"
|
||||||
|
>
|
||||||
|
<Redo2 size={14} />
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
|
<Table2 size={14} /> MD
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
|
<HelpCircle size={14} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative"
|
||||||
|
|||||||
@@ -115,3 +115,13 @@
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Edge flow animation — dot traveling from source to target */
|
||||||
|
@keyframes flow-dot {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: -10000; }
|
||||||
|
}
|
||||||
|
.edge-flow-dot {
|
||||||
|
animation: flow-dot 2.5s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ describe('canvasStore', () => {
|
|||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -234,4 +237,95 @@ describe('canvasStore', () => {
|
|||||||
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
||||||
expect(parentIdx).toBeLessThan(childIdx)
|
expect(parentIdx).toBeLessThan(childIdx)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- History (undo/redo) ---
|
||||||
|
|
||||||
|
it('snapshotHistory pushes current state to past and clears future', () => {
|
||||||
|
const { addNode, snapshotHistory } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
const { past, future } = useCanvasStore.getState()
|
||||||
|
expect(past).toHaveLength(1)
|
||||||
|
expect(past[0].nodes).toHaveLength(1)
|
||||||
|
expect(future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo restores previous state and moves current to future', () => {
|
||||||
|
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
const { nodes, past, future } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(nodes[0].id).toBe('n1')
|
||||||
|
expect(past).toHaveLength(0)
|
||||||
|
expect(future).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redo re-applies undone state', () => {
|
||||||
|
const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
redo()
|
||||||
|
const { nodes, future } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(2)
|
||||||
|
expect(future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo does nothing when past is empty', () => {
|
||||||
|
const { addNode, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
undo()
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapshotHistory clears future (new branch)', () => {
|
||||||
|
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
// now take a new action
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n3'))
|
||||||
|
expect(useCanvasStore.getState().future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Clipboard (copy/paste) ---
|
||||||
|
|
||||||
|
it('copySelectedNodes stores only selected nodes', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ ...makeNode('a'), selected: true },
|
||||||
|
{ ...makeNode('b'), selected: false },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
const { clipboard } = useCanvasStore.getState()
|
||||||
|
expect(clipboard).toHaveLength(1)
|
||||||
|
expect(clipboard[0].id).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
||||||
|
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(2)
|
||||||
|
const pasted = nodes.find((n) => n.id !== 'src')!
|
||||||
|
expect(pasted).toBeDefined()
|
||||||
|
expect(pasted.position.x).toBe(150)
|
||||||
|
expect(pasted.position.y).toBe(150)
|
||||||
|
expect(pasted.selected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
@@ -18,6 +20,18 @@ interface CanvasState {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
|
|
||||||
|
// History
|
||||||
|
past: HistoryEntry[]
|
||||||
|
future: HistoryEntry[]
|
||||||
|
snapshotHistory: () => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
clipboard: Node<NodeData>[]
|
||||||
|
copySelectedNodes: () => void
|
||||||
|
pasteNodes: () => void
|
||||||
|
|
||||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
onConnect: (connection: Connection) => void
|
onConnect: (connection: Connection) => void
|
||||||
@@ -48,6 +62,67 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hideIp: false,
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
|
|
||||||
|
snapshotHistory: () =>
|
||||||
|
set((state) => ({
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
})),
|
||||||
|
|
||||||
|
undo: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.past.length === 0) return state
|
||||||
|
const previous = state.past[state.past.length - 1]
|
||||||
|
return {
|
||||||
|
nodes: previous.nodes,
|
||||||
|
edges: previous.edges,
|
||||||
|
past: state.past.slice(0, -1),
|
||||||
|
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
redo: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.future.length === 0) return state
|
||||||
|
const next = state.future[0]
|
||||||
|
return {
|
||||||
|
nodes: next.nodes,
|
||||||
|
edges: next.edges,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: state.future.slice(1),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
copySelectedNodes: () =>
|
||||||
|
set((state) => ({
|
||||||
|
clipboard: state.nodes.filter((n) => n.selected),
|
||||||
|
})),
|
||||||
|
|
||||||
|
pasteNodes: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.clipboard.length === 0) return state
|
||||||
|
const newNodes = state.clipboard.map((n) => ({
|
||||||
|
...n,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||||
|
selected: false,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
data: { ...n.data, parent_id: undefined },
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
nodes: [...state.nodes, ...newNodes],
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
onNodesChange: (changes) =>
|
onNodesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: applyNodeChanges(changes, state.nodes),
|
nodes: applyNodeChanges(changes, state.nodes),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
speed?: string
|
speed?: string
|
||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
|
animated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { generateMarkdownTable } from '../exportMarkdown'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<NodeData> = {}, id = '1'): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateMarkdownTable', () => {
|
||||||
|
it('returns empty string for empty node list', () => {
|
||||||
|
expect(generateMarkdownTable([])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes', () => {
|
||||||
|
const nodes = [makeNode({ type: 'groupRect', label: 'Zone' })]
|
||||||
|
expect(generateMarkdownTable(nodes)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates header + separator + row', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Router', type: 'router', ip: '192.168.1.1', status: 'online' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
const lines = md.split('\n')
|
||||||
|
expect(lines[0]).toContain('Label')
|
||||||
|
expect(lines[0]).toContain('IP')
|
||||||
|
expect(lines[1]).toContain('---')
|
||||||
|
expect(lines[2]).toContain('Router')
|
||||||
|
expect(lines[2]).toContain('192.168.1.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses — for missing fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Node', type: 'generic', ip: undefined, hostname: undefined })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists services as name:port pairs', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Server',
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }, { port: 443, protocol: 'tcp', service_name: 'https' }],
|
||||||
|
})]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('nginx:80')
|
||||||
|
expect(md).toContain('https:443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes pipe characters in cell values', () => {
|
||||||
|
const nodes = [makeNode({ label: 'A|B' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('A\\|B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates one row per non-groupRect node', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ type: 'server', label: 'A' }, '1'),
|
||||||
|
makeNode({ type: 'router', label: 'B' }, '2'),
|
||||||
|
makeNode({ type: 'groupRect', label: 'Zone' }, '3'),
|
||||||
|
]
|
||||||
|
const lines = generateMarkdownTable(nodes).split('\n')
|
||||||
|
// header + separator + 2 data rows
|
||||||
|
expect(lines).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const EMPTY = '—'
|
||||||
|
|
||||||
|
function cell(v: string | null | undefined): string {
|
||||||
|
if (!v) return EMPTY
|
||||||
|
// Escape pipe chars so they don't break the table
|
||||||
|
return v.replace(/\|/g, '\\|')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||||
|
const rows = nodes
|
||||||
|
.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
.map((n) => {
|
||||||
|
const d = n.data
|
||||||
|
const services = d.services?.length
|
||||||
|
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
|
||||||
|
: EMPTY
|
||||||
|
return [
|
||||||
|
cell(d.label),
|
||||||
|
cell(d.type),
|
||||||
|
cell(d.ip),
|
||||||
|
cell(d.hostname),
|
||||||
|
cell(d.status),
|
||||||
|
services,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rows.length === 0) return ''
|
||||||
|
|
||||||
|
const headers = ['Label', 'Type', 'IP', 'Hostname', 'Status', 'Services']
|
||||||
|
const separator = headers.map(() => '---')
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`| ${headers.join(' | ')} |`,
|
||||||
|
`| ${separator.join(' | ')} |`,
|
||||||
|
...rows.map((r) => `| ${r.join(' | ')} |`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user