feat: lasso selection, multi-select panel, and named groups
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default) - Add lasso/pan toggle button in canvas controls (bottom-left) - Multi-select panel in right panel when 2+ nodes selected (including zones) - Create named Group node from selected nodes with bounding box math - Group node: resizable, inline rename, show/hide border toggle, status summary - GroupDetailPanel: lists members, online/offline count, ungroup action - Fix group persistence after save/reload (extend proxmox container map to include group nodes) - Fix groupRect serialization to preserve parent_id - Remove background color from group and proxmox container node wrappers - Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
This commit is contained in:
@@ -35,7 +35,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, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, 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()
|
||||||
@@ -100,8 +100,8 @@ export default function App() {
|
|||||||
// Build a map of proxmox container mode to know if children should be nested
|
// Build a map of proxmox container mode to know if children should be nested
|
||||||
const proxmoxContainerMap = new Map<string, boolean>(
|
const proxmoxContainerMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n) => n.type === 'proxmox')
|
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
||||||
.map((n) => [n.id, n.container_mode !== false])
|
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||||
)
|
)
|
||||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
@@ -385,7 +385,7 @@ export default function App() {
|
|||||||
<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} onNodeDragStart={snapshotHistory} />
|
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||||
</div>
|
</div>
|
||||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ function LiveViewCanvas() {
|
|||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: ApiNode) => n.type === 'proxmox')
|
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
||||||
.map((n: ApiNode) => [n.id, n.container_mode !== false])
|
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||||
)
|
)
|
||||||
loadCanvas(
|
loadCanvas(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
|
ControlButton,
|
||||||
BackgroundVariant,
|
BackgroundVariant,
|
||||||
ConnectionMode,
|
ConnectionMode,
|
||||||
|
SelectionMode,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
type Connection,
|
type Connection,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
|
import { MousePointer2, Hand } from 'lucide-react'
|
||||||
import '@xyflow/react/dist/style.css'
|
import '@xyflow/react/dist/style.css'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
@@ -24,6 +27,7 @@ interface CanvasContainerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||||
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
@@ -33,8 +37,12 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
setSelectedNode(null)
|
||||||
|
} else {
|
||||||
setSelectedNode(node.id)
|
setSelectedNode(node.id)
|
||||||
|
}
|
||||||
}, [setSelectedNode])
|
}, [setSelectedNode])
|
||||||
|
|
||||||
const onPaneClick = useCallback(() => {
|
const onPaneClick = useCallback(() => {
|
||||||
@@ -61,6 +69,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
||||||
|
selectionOnDrag={lassoMode}
|
||||||
|
panOnDrag={lassoMode ? [1, 2] : true}
|
||||||
|
panActivationKeyCode="Space"
|
||||||
|
selectionMode={SelectionMode.Partial}
|
||||||
|
multiSelectionKeyCode={['Meta', 'Control']}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[16, 16]}
|
snapGrid={[16, 16]}
|
||||||
fitView
|
fitView
|
||||||
@@ -75,7 +88,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
size={1}
|
size={1}
|
||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<Controls />
|
<Controls>
|
||||||
|
<ControlButton
|
||||||
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
|
||||||
|
>
|
||||||
|
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
|
||||||
|
</ControlButton>
|
||||||
|
</Controls>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
},
|
},
|
||||||
Background: () => null,
|
Background: () => null,
|
||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
|
ControlButton: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
SelectionMode: { Partial: 'partial' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -151,6 +153,55 @@ describe('CanvasContainer', () => {
|
|||||||
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
|
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Lasso / multi-select ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('enables selectionOnDrag for lasso selection', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.selectionOnDrag).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets panActivationKeyCode to Space', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.panActivationKeyCode).toBe('Space')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets panOnDrag to [1, 2]', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.panOnDrag).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets selectionMode to Partial', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.selectionMode).toBe('partial')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets multiSelectionKeyCode to Meta and Control', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
|
||||||
|
{ ctrlKey: true, metaKey: false } as unknown as MouseEvent,
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears selectedNode (sets null) on Cmd+click', () => {
|
||||||
|
const node = makeNode('n1')
|
||||||
|
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
|
||||||
|
{ ctrlKey: false, metaKey: true } as unknown as MouseEvent,
|
||||||
|
node,
|
||||||
|
)
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
|
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
|
||||||
|
|
||||||
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
|
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { GroupNode } from '../nodes/GroupNode'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||||
|
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||||
|
),
|
||||||
|
useReactFlow: () => ({}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
|
||||||
|
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'g1',
|
||||||
|
type: 'group',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
width: 400,
|
||||||
|
height: 250,
|
||||||
|
data: {
|
||||||
|
label: 'My Group',
|
||||||
|
type: 'group',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: { show_border: true },
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
|
||||||
|
const node = makeGroupNode(props.data)
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: storeNodes,
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<GroupNode
|
||||||
|
id="g1"
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
dragging={false}
|
||||||
|
zIndex={1}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
{...props}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GroupNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the group label when show_border is true', () => {
|
||||||
|
renderGroupNode()
|
||||||
|
expect(screen.getByText('My Group')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the header when show_border is false and not selected', () => {
|
||||||
|
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
|
||||||
|
expect(screen.queryByText('My Group')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows header when show_border is false but node is selected', () => {
|
||||||
|
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
|
||||||
|
expect(screen.getByText('My Group')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows NodeResizer only when selected', () => {
|
||||||
|
const { rerender } = renderGroupNode({ selected: false })
|
||||||
|
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
|
||||||
|
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
nodes: [],
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<GroupNode
|
||||||
|
id="g1"
|
||||||
|
data={makeGroupNode().data}
|
||||||
|
selected={true}
|
||||||
|
dragging={false}
|
||||||
|
zIndex={1}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows online/offline status summary from children', () => {
|
||||||
|
const storeNodes = [
|
||||||
|
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
||||||
|
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
|
||||||
|
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
|
||||||
|
]
|
||||||
|
|
||||||
|
renderGroupNode({}, storeNodes)
|
||||||
|
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
|
||||||
|
const statusSpans = screen.getAllByText(/● \d+/)
|
||||||
|
expect(statusSpans).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show status summary when group has no children', () => {
|
||||||
|
renderGroupNode()
|
||||||
|
expect(screen.queryByText(/●/)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
||||||
|
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||||
|
|
||||||
|
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
|
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||||
|
const showBorder = data.custom_colors?.show_border !== false
|
||||||
|
const isVisible = showBorder || selected
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [labelDraft, setLabelDraft] = useState(data.label)
|
||||||
|
|
||||||
|
const children = nodes.filter((n) => n.parentId === id)
|
||||||
|
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||||
|
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||||
|
const unknownCount = children.length - onlineCount - offlineCount
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
if (labelDraft.trim()) {
|
||||||
|
snapshotHistory()
|
||||||
|
updateNode(id, { label: labelDraft.trim() })
|
||||||
|
}
|
||||||
|
setEditing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColor = selected ? '#00d4ff' : '#30363d'
|
||||||
|
const borderStyle = selected ? 'solid' : 'dashed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={120}
|
||||||
|
minHeight={80}
|
||||||
|
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
|
||||||
|
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
{isVisible && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '5px 10px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
|
||||||
|
borderRadius: '6px 6px 0 0',
|
||||||
|
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
className="nodrag"
|
||||||
|
>
|
||||||
|
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={labelDraft}
|
||||||
|
onChange={(e) => setLabelDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleRename()
|
||||||
|
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
color: '#e6edf3',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||||
|
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
||||||
|
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
||||||
|
title="Rename group"
|
||||||
|
>
|
||||||
|
<Pencil size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status summary */}
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
||||||
|
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}>● {onlineCount}</span>}
|
||||||
|
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount}</span>}
|
||||||
|
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}>● {unknownCount}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
|
import { GroupNode } from './GroupNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
@@ -20,4 +21,5 @@ export const nodeTypes = {
|
|||||||
docker: DockerNode,
|
docker: DockerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
|
group: GroupNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,75 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||||
|
|
||||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore()
|
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
||||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
|
||||||
|
|
||||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||||
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
||||||
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
|
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||||
|
const [groupName, setGroupName] = useState('')
|
||||||
|
const [creatingGroup, setCreatingGroup] = useState(false)
|
||||||
|
|
||||||
|
// Multi-select panel
|
||||||
|
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
|
||||||
|
|
||||||
|
if (multiSelected.length > 1) {
|
||||||
|
return (
|
||||||
|
<MultiSelectPanel
|
||||||
|
nodeIds={multiSelected}
|
||||||
|
nodes={nodes}
|
||||||
|
groupName={groupName}
|
||||||
|
setGroupName={setGroupName}
|
||||||
|
creatingGroup={creatingGroup}
|
||||||
|
setCreatingGroup={setCreatingGroup}
|
||||||
|
onCreateGroup={(name) => { createGroup(multiSelected, name); setGroupName(''); setCreatingGroup(false) }}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||||
if (!node || node.data.type === 'groupRect') return null
|
if (!node || node.data.type === 'groupRect') return null
|
||||||
|
|
||||||
|
// Group detail panel
|
||||||
|
if (node.data.type === 'group') {
|
||||||
|
return (
|
||||||
|
<GroupDetailPanel
|
||||||
|
node={node}
|
||||||
|
nodes={nodes}
|
||||||
|
onUngroup={() => { ungroup(node.id) }}
|
||||||
|
onToggleBorder={() => {
|
||||||
|
snapshotHistory()
|
||||||
|
updateNode(node.id, {
|
||||||
|
custom_colors: {
|
||||||
|
...node.data.custom_colors,
|
||||||
|
show_border: !(node.data.custom_colors?.show_border !== false),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => setSelectedNode(null)}
|
||||||
|
onSelectChild={(id) => setSelectedNode(id)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal single-node panel
|
||||||
const addingService = addingForNode === node.id
|
const addingService = addingForNode === node.id
|
||||||
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
|
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
|
||||||
|
|
||||||
const { data } = node
|
const { data } = node
|
||||||
const services = data.services ?? []
|
const services = data.services ?? []
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
@@ -43,11 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const handleAddService = () => {
|
const handleAddService = () => {
|
||||||
const port = parseInt(newSvc.port, 10)
|
const port = parseInt(newSvc.port, 10)
|
||||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||||
const svc: ServiceInfo = {
|
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||||
port,
|
|
||||||
protocol: newSvc.protocol,
|
|
||||||
service_name: newSvc.service_name.trim(),
|
|
||||||
}
|
|
||||||
updateNode(node.id, { services: [...services, svc] })
|
updateNode(node.id, { services: [...services, svc] })
|
||||||
setNewSvc(EMPTY_FORM)
|
setNewSvc(EMPTY_FORM)
|
||||||
setAddingForNode(null)
|
setAddingForNode(null)
|
||||||
@@ -72,9 +110,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const port = parseInt(editSvc.port, 10)
|
const port = parseInt(editSvc.port, 10)
|
||||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||||
const updated = services.map((svc, i) =>
|
const updated = services.map((svc, i) =>
|
||||||
i === editingIndex
|
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||||
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
|
|
||||||
: svc
|
|
||||||
)
|
)
|
||||||
updateNode(node.id, { services: updated })
|
updateNode(node.id, { services: updated })
|
||||||
setEditingFor(null)
|
setEditingFor(null)
|
||||||
@@ -82,19 +118,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||||
<button
|
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
aria-label="Close panel"
|
|
||||||
onClick={() => setSelectedNode(null)}
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
|
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
|
||||||
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
|
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
|
||||||
@@ -103,21 +133,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 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 && (
|
{data.hostname && (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
||||||
<a
|
<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}>
|
||||||
href={`http://${data.hostname}`}
|
{data.hostname}<ExternalLink size={10} className="shrink-0" />
|
||||||
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -125,12 +147,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{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} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && (
|
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
|
||||||
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hardware */}
|
|
||||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||||
@@ -141,64 +160,28 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Services */}
|
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||||
Services{services.length > 0 ? ` (${services.length})` : ''}
|
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }}
|
|
||||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus size={10} /> Add
|
<Plus size={10} /> Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
|
||||||
{/* Add service form */}
|
|
||||||
{addingService && (
|
|
||||||
<ServiceForm
|
|
||||||
form={newSvc}
|
|
||||||
onChange={setNewSvc}
|
|
||||||
onConfirm={handleAddService}
|
|
||||||
onCancel={() => setAddingForNode(null)}
|
|
||||||
confirmLabel="Add"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{services.length > 0 && (
|
{services.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{services.map((svc, i) =>
|
{services.map((svc, i) =>
|
||||||
editingIndex === i ? (
|
editingIndex === i ? (
|
||||||
<ServiceForm
|
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||||
key={`edit-${i}`}
|
|
||||||
form={editSvc}
|
|
||||||
onChange={setEditSvc}
|
|
||||||
onConfirm={handleSaveEdit}
|
|
||||||
onCancel={() => setEditingFor(null)}
|
|
||||||
confirmLabel="Save"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<ServiceBadge
|
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||||
key={`${svc.port}-${svc.protocol}-${i}`}
|
|
||||||
svc={svc}
|
|
||||||
host={host}
|
|
||||||
onEdit={() => handleStartEdit(i)}
|
|
||||||
onRemove={() => handleRemoveService(i)}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>}
|
||||||
{services.length === 0 && !addingService && (
|
|
||||||
<p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
{data.notes && (
|
{data.notes && (
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
||||||
@@ -206,7 +189,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
||||||
<Edit size={14} /> Edit
|
<Edit size={14} /> Edit
|
||||||
@@ -219,6 +201,167 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Multi-select panel ---
|
||||||
|
|
||||||
|
interface MultiSelectPanelProps {
|
||||||
|
nodeIds: string[]
|
||||||
|
nodes: Node<NodeData>[]
|
||||||
|
groupName: string
|
||||||
|
setGroupName: (v: string) => void
|
||||||
|
creatingGroup: boolean
|
||||||
|
setCreatingGroup: (v: boolean) => void
|
||||||
|
onCreateGroup: (name: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGroup, setCreatingGroup, onCreateGroup, onClose }: MultiSelectPanelProps) {
|
||||||
|
const selectedNodes = nodeIds.map((id) => nodes.find((n) => n.id === id)).filter(Boolean) as Node<NodeData>[]
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
const name = groupName.trim() || 'Group'
|
||||||
|
onCreateGroup(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers size={14} className="text-[#00d4ff]" />
|
||||||
|
<span className="font-semibold text-sm text-foreground">{nodeIds.length} nodes selected</span>
|
||||||
|
</div>
|
||||||
|
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||||||
|
{selectedNodes.map((n) => (
|
||||||
|
<div key={n.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[n.data.status] }} />
|
||||||
|
<span className="truncate text-foreground font-medium">{n.data.label}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 border-t border-border space-y-2">
|
||||||
|
{creatingGroup ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Group name…"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => setGroupName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingGroup(false) }}
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" className="flex-1 h-7 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={handleCreate}>
|
||||||
|
Create Group
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 text-[10px]" onClick={() => setCreatingGroup(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setCreatingGroup(true)}
|
||||||
|
>
|
||||||
|
<Layers size={13} /> Create Group
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group detail panel ---
|
||||||
|
|
||||||
|
interface GroupDetailPanelProps {
|
||||||
|
node: Node<NodeData>
|
||||||
|
nodes: Node<NodeData>[]
|
||||||
|
onUngroup: () => void
|
||||||
|
onToggleBorder: () => void
|
||||||
|
onClose: () => void
|
||||||
|
onSelectChild: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
||||||
|
const children = nodes.filter((n) => n.parentId === node.id)
|
||||||
|
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||||
|
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||||
|
const showBorder = node.data.custom_colors?.show_border !== false
|
||||||
|
|
||||||
|
const handleUngroup = () => {
|
||||||
|
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
|
||||||
|
onUngroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Layers size={14} className="text-[#00d4ff] shrink-0" />
|
||||||
|
<span className="font-semibold text-sm text-foreground truncate">{node.data.label}</span>
|
||||||
|
</div>
|
||||||
|
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status summary */}
|
||||||
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-border text-xs">
|
||||||
|
<span className="text-muted-foreground">{children.length} node{children.length !== 1 ? 's' : ''}</span>
|
||||||
|
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}>● {onlineCount} online</span>}
|
||||||
|
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children list */}
|
||||||
|
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
||||||
|
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
||||||
|
{children.map((child) => (
|
||||||
|
<button
|
||||||
|
key={child.id}
|
||||||
|
onClick={() => onSelectChild(child.id)}
|
||||||
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
||||||
|
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
||||||
|
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="px-4 py-3 border-t border-border space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={onToggleBorder}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-[#21262d] transition-colors"
|
||||||
|
>
|
||||||
|
{showBorder ? <Eye size={13} /> : <EyeOff size={13} />}
|
||||||
|
{showBorder ? 'Hide border & title' : 'Show border & title'}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handleUngroup}
|
||||||
|
>
|
||||||
|
<Ungroup size={13} /> Ungroup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
function formatStorage(gb: number): string {
|
function formatStorage(gb: number): string {
|
||||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||||
return `${gb} GB`
|
return `${gb} GB`
|
||||||
@@ -228,24 +371,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
|||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
|
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
|
||||||
<span
|
<span className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`} title={value}>
|
||||||
className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`}
|
|
||||||
title={value}
|
|
||||||
>
|
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceForm({
|
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||||
form,
|
|
||||||
onChange,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
confirmLabel,
|
|
||||||
autoFocus,
|
|
||||||
}: {
|
|
||||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
@@ -255,82 +388,31 @@ function ServiceForm({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||||
<Input
|
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||||
value={form.service_name}
|
|
||||||
onChange={(e) => onChange({ ...form, service_name: e.target.value })}
|
|
||||||
placeholder="Service name"
|
|
||||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Input
|
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
|
||||||
type="number"
|
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
|
||||||
value={form.port}
|
|
||||||
onChange={(e) => onChange({ ...form, port: e.target.value })}
|
|
||||||
placeholder="Port"
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={form.protocol}
|
|
||||||
onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })}
|
|
||||||
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
|
|
||||||
>
|
|
||||||
<option value="tcp">tcp</option>
|
<option value="tcp">tcp</option>
|
||||||
<option value="udp">udp</option>
|
<option value="udp">udp</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<Button
|
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
|
||||||
size="sm"
|
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
|
||||||
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
|
||||||
onClick={onConfirm}
|
|
||||||
>
|
|
||||||
{confirmLabel}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_COLORS: Record<string, string> = {
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
web: '#00d4ff',
|
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||||
database: '#a855f7',
|
|
||||||
monitoring: '#39d353',
|
|
||||||
storage: '#e3b341',
|
|
||||||
security: '#f85149',
|
|
||||||
remote: '#8b949e',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceBadge({
|
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||||
svc,
|
|
||||||
host,
|
|
||||||
onEdit,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
svc: ServiceInfo
|
|
||||||
host?: string
|
|
||||||
onEdit: () => void
|
|
||||||
onRemove: () => void
|
|
||||||
}) {
|
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<div
|
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
|
||||||
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
|
|
||||||
style={{
|
|
||||||
background: '#21262d',
|
|
||||||
borderColor: '#30363d',
|
|
||||||
cursor: url ? 'pointer' : 'default',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
||||||
@@ -338,30 +420,11 @@ function ServiceBadge({
|
|||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||||
<button
|
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
|
|
||||||
title="Edit service"
|
|
||||||
>
|
|
||||||
<Pencil size={10} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
|
||||||
title="Remove service"
|
|
||||||
>
|
|
||||||
<X size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
|
||||||
if (url) {
|
|
||||||
return (
|
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">
|
|
||||||
{inner}
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return inner
|
return inner
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
|
|||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
nodes: [makeNode(nodeData)],
|
nodes: [makeNode(nodeData)],
|
||||||
selectedNodeId: 'n1',
|
selectedNodeId: 'n1',
|
||||||
|
selectedNodeIds: [],
|
||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,10 +41,13 @@ describe('DetailPanel', () => {
|
|||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { DetailPanel } from '../DetailPanel'
|
||||||
|
import * as canvasStore from '@/stores/canvasStore'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
vi.mock('@/utils/serviceUrl', () => ({ getServiceUrl: () => null }))
|
||||||
|
|
||||||
|
function makeNode(id: string, overrides = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: id, type: 'server', status: 'online', services: [] },
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'group',
|
||||||
|
position: { x: 76, y: 52 },
|
||||||
|
data: {
|
||||||
|
label,
|
||||||
|
type: 'group',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: { show_border: showBorder },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockStore = {
|
||||||
|
nodes: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
|
setSelectedNode: vi.fn(),
|
||||||
|
deleteNode: vi.fn(),
|
||||||
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
|
createGroup: vi.fn(),
|
||||||
|
ungroup: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupStore(overrides = {}) {
|
||||||
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
|
...mockStore,
|
||||||
|
...overrides,
|
||||||
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel() {
|
||||||
|
return render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<DetailPanel onEdit={vi.fn()} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MultiSelectPanel', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders multi-select panel when 2+ nodes selected', () => {
|
||||||
|
const n1 = makeNode('n1', { data: { label: 'Router', type: 'router', status: 'online', services: [] } })
|
||||||
|
const n2 = makeNode('n2', { data: { label: 'Switch', type: 'switch', status: 'offline', services: [] } })
|
||||||
|
setupStore({
|
||||||
|
nodes: [n1, n2],
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: ['n1', 'n2'],
|
||||||
|
})
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText('2 nodes selected')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists selected node labels in multi-select panel', () => {
|
||||||
|
const n1 = makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
|
||||||
|
const n2 = makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'unknown', services: [] } })
|
||||||
|
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText('My Router')).toBeDefined()
|
||||||
|
expect(screen.getByText('My NAS')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Create Group button', () => {
|
||||||
|
const n1 = makeNode('n1')
|
||||||
|
const n2 = makeNode('n2')
|
||||||
|
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByRole('button', { name: /create group/i })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows name input when Create Group is clicked', async () => {
|
||||||
|
const n1 = makeNode('n1')
|
||||||
|
const n2 = makeNode('n2')
|
||||||
|
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText(/group name/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls createGroup with selected ids and entered name', async () => {
|
||||||
|
const createGroup = vi.fn()
|
||||||
|
const n1 = makeNode('n1')
|
||||||
|
const n2 = makeNode('n2')
|
||||||
|
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
|
||||||
|
const input = await screen.findByPlaceholderText(/group name/i)
|
||||||
|
fireEvent.change(input, { target: { value: 'DMZ' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
|
||||||
|
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'DMZ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses default name "Group" when input is empty', async () => {
|
||||||
|
const createGroup = vi.fn()
|
||||||
|
const n1 = makeNode('n1')
|
||||||
|
const n2 = makeNode('n2')
|
||||||
|
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
|
||||||
|
await screen.findByPlaceholderText(/group name/i)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
|
||||||
|
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'Group')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes groupRect (zone) nodes in multi-select count', () => {
|
||||||
|
const n1 = makeNode('n1')
|
||||||
|
const gr = makeNode('gr1', { data: { label: 'Zone', type: 'groupRect', status: 'unknown', services: [] } })
|
||||||
|
setupStore({ nodes: [n1, gr], selectedNodeId: null, selectedNodeIds: ['n1', 'gr1'] })
|
||||||
|
renderPanel()
|
||||||
|
// groupRect included → 2 nodes selected → multi-select panel shown
|
||||||
|
expect(screen.getByText('2 nodes selected')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GroupDetailPanel', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
it('renders group name and members heading', () => {
|
||||||
|
const group = makeGroupNode()
|
||||||
|
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
|
||||||
|
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText('My Group')).toBeDefined()
|
||||||
|
expect(screen.getByText('Members')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists children with their labels', () => {
|
||||||
|
const group = makeGroupNode()
|
||||||
|
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
|
||||||
|
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'My NAS', type: 'nas', status: 'offline', services: [] } })
|
||||||
|
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText('My Router')).toBeDefined()
|
||||||
|
expect(screen.getByText('My NAS')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows online/offline count in status summary', () => {
|
||||||
|
const group = makeGroupNode()
|
||||||
|
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'A', type: 'server', status: 'online', services: [] } })
|
||||||
|
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'B', type: 'server', status: 'offline', services: [] } })
|
||||||
|
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText(/1 online/)).toBeDefined()
|
||||||
|
expect(screen.getByText(/1 offline/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Ungroup button', () => {
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByRole('button', { name: /ungroup/i })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls ungroup after confirm', () => {
|
||||||
|
const ungroup = vi.fn()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
|
||||||
|
expect(ungroup).toHaveBeenCalledWith('g1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not call ungroup when confirm is cancelled', () => {
|
||||||
|
const ungroup = vi.fn()
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
const group = makeGroupNode()
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
|
||||||
|
expect(ungroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Hide border & title" when show_border is true', () => {
|
||||||
|
const group = makeGroupNode('g1', 'G', true)
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText(/hide border/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows "Show border & title" when show_border is false', () => {
|
||||||
|
const group = makeGroupNode('g1', 'G', false)
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||||
|
renderPanel()
|
||||||
|
expect(screen.getByText(/show border/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls updateNode to toggle show_border off', () => {
|
||||||
|
const updateNode = vi.fn()
|
||||||
|
const group = makeGroupNode('g1', 'G', true)
|
||||||
|
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByText(/hide border/i))
|
||||||
|
expect(updateNode).toHaveBeenCalledWith('g1', expect.objectContaining({
|
||||||
|
custom_colors: expect.objectContaining({ show_border: false }),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls setSelectedNode when a child node is clicked', () => {
|
||||||
|
const setSelectedNode = vi.fn()
|
||||||
|
const group = makeGroupNode()
|
||||||
|
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Child Node Alpha', type: 'server', status: 'online', services: [] } })
|
||||||
|
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], setSelectedNode })
|
||||||
|
renderPanel()
|
||||||
|
fireEvent.click(screen.getByText('Child Node Alpha'))
|
||||||
|
expect(setSelectedNode).toHaveBeenCalledWith('c1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -111,6 +111,15 @@
|
|||||||
background-color: var(--surface-card) !important;
|
background-color: var(--surface-card) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Transparent wrapper for container node types */
|
||||||
|
.react-flow__node-proxmox,
|
||||||
|
.react-flow__node-group {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mono font utility */
|
/* Mono font utility */
|
||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
@@ -178,6 +179,178 @@ describe('canvasStore', () => {
|
|||||||
expect(child?.extent).toBe('parent')
|
expect(child?.extent).toBe('parent')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── selectedNodeIds ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('selectedNodeIds starts empty', () => {
|
||||||
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onNodesChange syncs selectedNodeIds from select changes', () => {
|
||||||
|
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||||
|
useCanvasStore.getState().addNode(makeNode('n2'))
|
||||||
|
useCanvasStore.getState().onNodesChange([
|
||||||
|
{ type: 'select', id: 'n1', selected: true },
|
||||||
|
{ type: 'select', id: 'n2', selected: true },
|
||||||
|
])
|
||||||
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2']))
|
||||||
|
expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSelectedNode(null) resets selectedNodeIds to empty', () => {
|
||||||
|
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
|
||||||
|
useCanvasStore.getState().setSelectedNode(null)
|
||||||
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSelectedNode(id) preserves existing selectedNodeIds', () => {
|
||||||
|
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
|
||||||
|
useCanvasStore.getState().setSelectedNode('n1')
|
||||||
|
// does NOT wipe selectedNodeIds when setting a specific id
|
||||||
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1', 'n2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── createGroup ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('createGroup creates a group node at the bounding box of selected nodes', () => {
|
||||||
|
// n1 at (100,100), n2 at (300,200); both default to 200x80
|
||||||
|
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
|
||||||
|
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
|
||||||
|
useCanvasStore.setState({ nodes: [n1, n2] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const group = nodes.find((n) => n.data.type === 'group')
|
||||||
|
expect(group).toBeDefined()
|
||||||
|
expect(group?.data.label).toBe('My Group')
|
||||||
|
// groupX = 100-24=76, groupY = 100-48=52
|
||||||
|
expect(group?.position.x).toBe(76)
|
||||||
|
expect(group?.position.y).toBe(52)
|
||||||
|
// groupW = (500-100)+48=448, groupH = (280-100)+48+24=252
|
||||||
|
expect(group?.width).toBe(448)
|
||||||
|
expect(group?.height).toBe(252)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createGroup converts children to relative positions', () => {
|
||||||
|
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
|
||||||
|
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
|
||||||
|
useCanvasStore.setState({ nodes: [n1, n2] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const c1 = nodes.find((n) => n.id === 'n1')
|
||||||
|
const c2 = nodes.find((n) => n.id === 'n2')
|
||||||
|
// groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148)
|
||||||
|
expect(c1?.position).toEqual({ x: 24, y: 48 })
|
||||||
|
expect(c2?.position).toEqual({ x: 224, y: 148 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createGroup sets parentId and extent on children', () => {
|
||||||
|
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
|
||||||
|
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
|
||||||
|
useCanvasStore.setState({ nodes: [n1, n2] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const group = nodes.find((n) => n.data.type === 'group')!
|
||||||
|
const c1 = nodes.find((n) => n.id === 'n1')
|
||||||
|
const c2 = nodes.find((n) => n.id === 'n2')
|
||||||
|
expect(c1?.parentId).toBe(group.id)
|
||||||
|
expect(c1?.extent).toBe('parent')
|
||||||
|
expect(c2?.parentId).toBe(group.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createGroup places the group node before its children in the array', () => {
|
||||||
|
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
|
||||||
|
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
|
||||||
|
useCanvasStore.setState({ nodes: [n1, n2] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const groupIdx = nodes.findIndex((n) => n.data.type === 'group')
|
||||||
|
const c1Idx = nodes.findIndex((n) => n.id === 'n1')
|
||||||
|
const c2Idx = nodes.findIndex((n) => n.id === 'n2')
|
||||||
|
expect(groupIdx).toBeLessThan(c1Idx)
|
||||||
|
expect(groupIdx).toBeLessThan(c2Idx)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createGroup snapshots history and marks unsaved', () => {
|
||||||
|
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
|
||||||
|
useCanvasStore.setState({ nodes: [n1] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().createGroup(['n1'], 'G')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('createGroup clears selection', () => {
|
||||||
|
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
|
||||||
|
useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().createGroup(['n1'], 'G')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||||
|
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ungroup ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('ungroup restores children to absolute positions', () => {
|
||||||
|
const group = {
|
||||||
|
...makeNode('g1', { type: 'group', label: 'G' }),
|
||||||
|
position: { x: 76, y: 52 },
|
||||||
|
}
|
||||||
|
const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, c1, c2] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().ungroup('g1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const r1 = nodes.find((n) => n.id === 'n1')
|
||||||
|
const r2 = nodes.find((n) => n.id === 'n2')
|
||||||
|
expect(r1?.position).toEqual({ x: 100, y: 100 })
|
||||||
|
expect(r2?.position).toEqual({ x: 300, y: 200 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ungroup removes parentId and extent from children', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [group, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().ungroup('g1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const released = nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(released?.parentId).toBeUndefined()
|
||||||
|
expect(released?.extent).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ungroup deletes the group node', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().ungroup('g1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ungroup snapshots history and marks unsaved', () => {
|
||||||
|
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||||
|
useCanvasStore.setState({ nodes: [group] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().ungroup('g1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('updateEdge updates edge data and marks unsaved', () => {
|
it('updateEdge updates edge data and marks unsaved', () => {
|
||||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||||
useCanvasStore.getState().markSaved()
|
useCanvasStore.getState().markSaved()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface CanvasState {
|
|||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
hasUnsavedChanges: boolean
|
hasUnsavedChanges: boolean
|
||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
|
selectedNodeIds: string[]
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
|
|
||||||
// History
|
// History
|
||||||
@@ -46,6 +47,8 @@ interface CanvasState {
|
|||||||
setNodeZIndex: (id: string, zIndex: number) => void
|
setNodeZIndex: (id: string, zIndex: number) => void
|
||||||
editingGroupRectId: string | null
|
editingGroupRectId: string | null
|
||||||
setEditingGroupRectId: (id: string | null) => void
|
setEditingGroupRectId: (id: string | null) => void
|
||||||
|
createGroup: (nodeIds: string[], name: string) => void
|
||||||
|
ungroup: (groupId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
@@ -59,6 +62,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
hideIp: false,
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
@@ -125,10 +129,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
onNodesChange: (changes) =>
|
onNodesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
nodes: applyNodeChanges(changes, state.nodes),
|
const nodes = applyNodeChanges(changes, state.nodes)
|
||||||
|
const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
selectedNodeIds,
|
||||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||||
})),
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
onEdgesChange: (changes) =>
|
onEdgesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -156,7 +165,10 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setSelectedNode: (id) => set({ selectedNodeId: id }),
|
setSelectedNode: (id) => set((state) => ({
|
||||||
|
selectedNodeId: id,
|
||||||
|
selectedNodeIds: id ? state.selectedNodeIds : [],
|
||||||
|
})),
|
||||||
|
|
||||||
addNode: (node) =>
|
addNode: (node) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -244,6 +256,112 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||||
|
|
||||||
|
createGroup: (nodeIds, name) =>
|
||||||
|
set((state) => {
|
||||||
|
const PADDING_H = 24
|
||||||
|
const PADDING_TOP = 48
|
||||||
|
const PADDING_BOTTOM = 24
|
||||||
|
const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
|
||||||
|
if (targets.length === 0) return state
|
||||||
|
|
||||||
|
// Bounding box in absolute coordinates
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||||
|
for (const n of targets) {
|
||||||
|
const w = n.width ?? 200
|
||||||
|
const h = n.height ?? 80
|
||||||
|
minX = Math.min(minX, n.position.x)
|
||||||
|
minY = Math.min(minY, n.position.y)
|
||||||
|
maxX = Math.max(maxX, n.position.x + w)
|
||||||
|
maxY = Math.max(maxY, n.position.y + h)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupX = minX - PADDING_H
|
||||||
|
const groupY = minY - PADDING_TOP
|
||||||
|
const groupW = maxX - minX + PADDING_H * 2
|
||||||
|
const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM
|
||||||
|
|
||||||
|
const groupId = generateUUID()
|
||||||
|
const groupNode: Node<NodeData> = {
|
||||||
|
id: groupId,
|
||||||
|
type: 'group',
|
||||||
|
position: { x: groupX, y: groupY },
|
||||||
|
width: groupW,
|
||||||
|
height: groupH,
|
||||||
|
data: {
|
||||||
|
label: name,
|
||||||
|
type: 'group',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: { show_border: true },
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert children to relative positions and assign parentId
|
||||||
|
const updatedNodes = state.nodes.map((n) => {
|
||||||
|
if (!nodeIds.includes(n.id)) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: groupId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
position: {
|
||||||
|
x: n.position.x - groupX,
|
||||||
|
y: n.position.y - groupY,
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
data: { ...n.data, parent_id: groupId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group node must come before its children
|
||||||
|
const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
|
||||||
|
const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
|
||||||
|
const nodes = [...withoutTargets, groupNode, ...children]
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
selectedNodeIds: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
ungroup: (groupId) =>
|
||||||
|
set((state) => {
|
||||||
|
const group = state.nodes.find((n) => n.id === groupId)
|
||||||
|
if (!group) return state
|
||||||
|
|
||||||
|
const groupAbsX = group.position.x
|
||||||
|
const groupAbsY = group.position.y
|
||||||
|
|
||||||
|
const nodes = state.nodes
|
||||||
|
.filter((n) => n.id !== groupId)
|
||||||
|
.map((n) => {
|
||||||
|
if (n.parentId !== groupId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
position: {
|
||||||
|
x: n.position.x + groupAbsX,
|
||||||
|
y: n.position.y + groupAbsY,
|
||||||
|
},
|
||||||
|
data: { ...n.data, parent_id: undefined },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: [],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||||
|
|
||||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export type NodeType =
|
|||||||
| 'docker'
|
| 'docker'
|
||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
|
| 'group'
|
||||||
|
|
||||||
export type TextPosition =
|
export type TextPosition =
|
||||||
| 'top-left'
|
| 'top-left'
|
||||||
@@ -76,6 +77,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
label_position?: 'inside' | 'outside'
|
label_position?: 'inside' | 'outside'
|
||||||
text_size?: number
|
text_size?: number
|
||||||
z_order?: number
|
z_order?: number
|
||||||
|
show_border?: boolean
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
@@ -112,6 +114,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
docker: 'Docker Host',
|
docker: 'Docker Host',
|
||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
|
group: 'Node Group',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
check_target: null,
|
check_target: null,
|
||||||
services: [],
|
services: [],
|
||||||
notes: null,
|
notes: null,
|
||||||
parent_id: null,
|
parent_id: n.data.parent_id ?? null,
|
||||||
container_mode: false,
|
container_mode: false,
|
||||||
custom_icon: null,
|
custom_icon: null,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
@@ -142,6 +142,7 @@ export function deserializeApiNode(
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
zIndex: z - 10,
|
zIndex: z - 10,
|
||||||
|
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
||||||
|
|||||||
Reference in New Issue
Block a user