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'
|
||||
|
||||
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 { isAuthenticated } = useAuthStore()
|
||||
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
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'proxmox')
|
||||
.map((n) => [n.id, n.container_mode !== false])
|
||||
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
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">
|
||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||
</div>
|
||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,8 +71,8 @@ function LiveViewCanvas() {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const proxmoxMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n: ApiNode) => n.type === 'proxmox')
|
||||
.map((n: ApiNode) => [n.id, n.container_mode !== false])
|
||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
)
|
||||
loadCanvas(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
ControlButton,
|
||||
BackgroundVariant,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
} from '@xyflow/react'
|
||||
import { MousePointer2, Hand } from 'lucide-react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -24,6 +27,7 @@ interface CanvasContainerProps {
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
@@ -33,8 +37,12 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||
setSelectedNode(node.id)
|
||||
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedNode(null)
|
||||
} else {
|
||||
setSelectedNode(node.id)
|
||||
}
|
||||
}, [setSelectedNode])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
@@ -61,6 +69,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
||||
selectionOnDrag={lassoMode}
|
||||
panOnDrag={lassoMode ? [1, 2] : true}
|
||||
panActivationKeyCode="Space"
|
||||
selectionMode={SelectionMode.Partial}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
fitView
|
||||
@@ -75,7 +88,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
size={1}
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,8 +16,10 @@ vi.mock('@xyflow/react', () => ({
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
ControlButton: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
@@ -151,6 +153,55 @@ describe('CanvasContainer', () => {
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
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 { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
@@ -20,4 +21,5 @@ export const nodeTypes = {
|
||||
docker: DockerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
group: GroupNode,
|
||||
}
|
||||
|
||||
@@ -1,33 +1,75 @@
|
||||
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 { Input } from '@/components/ui/input'
|
||||
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 type { Node } from '@xyflow/react'
|
||||
|
||||
interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore()
|
||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
||||
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
||||
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
|
||||
|
||||
// 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 editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
|
||||
|
||||
const { data } = node
|
||||
const services = data.services ?? []
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
@@ -43,11 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const handleAddService = () => {
|
||||
const port = parseInt(newSvc.port, 10)
|
||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const svc: ServiceInfo = {
|
||||
port,
|
||||
protocol: newSvc.protocol,
|
||||
service_name: newSvc.service_name.trim(),
|
||||
}
|
||||
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
setAddingForNode(null)
|
||||
@@ -72,9 +110,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const port = parseInt(editSvc.port, 10)
|
||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex
|
||||
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
|
||||
: svc
|
||||
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
setEditingFor(null)
|
||||
@@ -82,19 +118,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||
<button
|
||||
aria-label="Close panel"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<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 }} />
|
||||
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
|
||||
@@ -103,21 +133,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
||||
{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 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>
|
||||
)}
|
||||
@@ -125,12 +147,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||
{data.last_seen && (
|
||||
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
|
||||
)}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{/* Hardware */}
|
||||
{(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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Services */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Services{services.length > 0 ? ` (${services.length})` : ''}
|
||||
</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"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add service form */}
|
||||
{addingService && (
|
||||
<ServiceForm
|
||||
form={newSvc}
|
||||
onChange={setNewSvc}
|
||||
onConfirm={handleAddService}
|
||||
onCancel={() => setAddingForNode(null)}
|
||||
confirmLabel="Add"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
|
||||
{services.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{services.map((svc, i) =>
|
||||
editingIndex === i ? (
|
||||
<ServiceForm
|
||||
key={`edit-${i}`}
|
||||
form={editSvc}
|
||||
onChange={setEditSvc}
|
||||
onConfirm={handleSaveEdit}
|
||||
onCancel={() => setEditingFor(null)}
|
||||
confirmLabel="Save"
|
||||
autoFocus
|
||||
/>
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
<ServiceBadge
|
||||
key={`${svc.port}-${svc.protocol}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
)
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* Notes */}
|
||||
{data.notes && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-1">Notes</div>
|
||||
@@ -206,7 +189,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<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)}>
|
||||
<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 {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
@@ -228,24 +371,14 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
return (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{label}</span>
|
||||
<span
|
||||
className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`}
|
||||
title={value}
|
||||
>
|
||||
<span className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`} title={value}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceForm({
|
||||
form,
|
||||
onChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel,
|
||||
autoFocus,
|
||||
}: {
|
||||
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
||||
onConfirm: () => void
|
||||
@@ -255,82 +388,31 @@ function ServiceForm({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<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()}
|
||||
/>
|
||||
<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()} />
|
||||
<div className="flex gap-1.5">
|
||||
<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()}
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<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()} />
|
||||
<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="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
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>
|
||||
<Button size="sm" 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>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff',
|
||||
database: '#a855f7',
|
||||
monitoring: '#39d353',
|
||||
storage: '#e3b341',
|
||||
security: '#f85149',
|
||||
remote: '#8b949e',
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
function ServiceBadge({
|
||||
svc,
|
||||
host,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: {
|
||||
svc: ServiceInfo
|
||||
host?: string
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||
|
||||
const inner = (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<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' }}>
|
||||
<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="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">
|
||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -26,10 +26,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode(nodeData)],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
}
|
||||
|
||||
@@ -38,10 +41,13 @@ describe('DetailPanel', () => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} 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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
@@ -178,6 +179,178 @@ describe('canvasStore', () => {
|
||||
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', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CanvasState {
|
||||
edges: Edge<EdgeData>[]
|
||||
hasUnsavedChanges: boolean
|
||||
selectedNodeId: string | null
|
||||
selectedNodeIds: string[]
|
||||
scanEventTs: number
|
||||
|
||||
// History
|
||||
@@ -46,6 +47,8 @@ interface CanvasState {
|
||||
setNodeZIndex: (id: string, zIndex: number) => void
|
||||
editingGroupRectId: string | null
|
||||
setEditingGroupRectId: (id: string | null) => void
|
||||
createGroup: (nodeIds: string[], name: string) => void
|
||||
ungroup: (groupId: string) => void
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||
@@ -59,6 +62,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
hideIp: false,
|
||||
scanEventTs: 0,
|
||||
@@ -125,10 +129,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}),
|
||||
|
||||
onNodesChange: (changes) =>
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes),
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||
})),
|
||||
set((state) => {
|
||||
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'),
|
||||
}
|
||||
}),
|
||||
|
||||
onEdgesChange: (changes) =>
|
||||
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) =>
|
||||
set((state) => {
|
||||
@@ -244,6 +256,112 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
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 }),
|
||||
|
||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||
|
||||
@@ -16,6 +16,7 @@ export type NodeType =
|
||||
| 'docker'
|
||||
| 'generic'
|
||||
| 'groupRect'
|
||||
| 'group'
|
||||
|
||||
export type TextPosition =
|
||||
| 'top-left'
|
||||
@@ -76,6 +77,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
label_position?: 'inside' | 'outside'
|
||||
text_size?: number
|
||||
z_order?: number
|
||||
show_border?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
@@ -112,6 +114,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
docker: 'Docker Host',
|
||||
generic: 'Generic Device',
|
||||
groupRect: 'Group Rectangle',
|
||||
group: 'Node Group',
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
check_target: null,
|
||||
services: [],
|
||||
notes: null,
|
||||
parent_id: null,
|
||||
parent_id: n.data.parent_id ?? null,
|
||||
container_mode: false,
|
||||
custom_icon: null,
|
||||
pos_x: n.position.x,
|
||||
@@ -142,6 +142,7 @@ export function deserializeApiNode(
|
||||
width: w,
|
||||
height: h,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user