feat: edge edit mode + proxmox container mode toggle
- Double-click any link to open Edit Link modal (type, label, VLAN ID, delete) - Add updateEdge / deleteEdge actions to canvasStore - Add container_mode field to proxmox nodes (backend model, schemas, migration) - NodeModal shows container toggle for proxmox type (defaults ON) - ProxmoxGroupNode renders as regular BaseNode when container_mode is OFF - setProxmoxContainerMode store action handles structural changes atomically (children parentId/extent, node dimensions) - CanvasContainer filters edges between container proxmox and its children - Canvas load respects container_mode when assigning parentId/extent
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -17,9 +17,10 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
interface CanvasContainerProps {
|
||||
onConnect?: (connection: Connection) => void
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
@@ -34,16 +35,38 @@ export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerPro
|
||||
setSelectedNode(null)
|
||||
}, [setSelectedNode])
|
||||
|
||||
const handleEdgeDoubleClick = useCallback((_: React.MouseEvent, edge: Edge<EdgeData>) => {
|
||||
onEdgeDoubleClick?.(edge)
|
||||
}, [onEdgeDoubleClick])
|
||||
|
||||
// Hide edges between a container-mode proxmox and its direct children
|
||||
const visibleEdges = useMemo(() => {
|
||||
const containerIds = new Set(
|
||||
nodes
|
||||
.filter((n) => n.type === 'proxmox' && n.data.container_mode !== false)
|
||||
.map((n) => n.id)
|
||||
)
|
||||
const childParentMap = new Map(
|
||||
nodes.filter((n) => n.data.parent_id).map((n) => [n.id, n.data.parent_id as string])
|
||||
)
|
||||
return (edges as Edge<EdgeData>[]).filter((e) => {
|
||||
if (containerIds.has(e.source) && childParentMap.get(e.target) === e.source) return false
|
||||
if (containerIds.has(e.target) && childParentMap.get(e.source) === e.target) return false
|
||||
return true
|
||||
})
|
||||
}, [nodes, edges])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges as Edge<EdgeData>[]}
|
||||
edges={visibleEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnectProp}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
snapToGrid
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Layers } from 'lucide-react'
|
||||
import type { NodeData, NodeStatus } from '@/types'
|
||||
import { BaseNode } from './BaseNode'
|
||||
|
||||
const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||
online: '#39d353',
|
||||
@@ -11,7 +12,14 @@ const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||
|
||||
const GLOW = '#ff6e00'
|
||||
|
||||
export function ProxmoxGroupNode({ data, selected }: NodeProps<Node<NodeData>>) {
|
||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const { data, selected } = props
|
||||
|
||||
// Render as a regular node when container mode is disabled
|
||||
if (data.container_mode === false) {
|
||||
return <BaseNode {...props} icon={Layers} glowColor={GLOW} />
|
||||
}
|
||||
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
const isOnline = data.status === 'online'
|
||||
|
||||
|
||||
@@ -12,12 +12,15 @@ interface EdgeModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: EdgeData) => void
|
||||
onDelete?: () => void
|
||||
initial?: Partial<EdgeData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
|
||||
const [type, setType] = useState<EdgeType>('ethernet')
|
||||
const [label, setLabel] = useState('')
|
||||
const [vlanId, setVlanId] = useState('')
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
||||
const [label, setLabel] = useState(initial?.label ?? '')
|
||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -27,16 +30,18 @@ export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
|
||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||
})
|
||||
onClose()
|
||||
setType('ethernet')
|
||||
setLabel('')
|
||||
setVlanId('')
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete?.()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Connect Nodes</DialogTitle>
|
||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 mt-2">
|
||||
@@ -79,11 +84,18 @@ export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||
Connect
|
||||
</Button>
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||
{onDelete ? 'Save' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
||||
status: 'unknown',
|
||||
check_method: 'ping',
|
||||
services: [],
|
||||
container_mode: true,
|
||||
}
|
||||
|
||||
interface NodeModalProps {
|
||||
@@ -153,6 +154,29 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container mode (proxmox only) */}
|
||||
{form.type === 'proxmox' && (
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.container_mode}
|
||||
onClick={() => set('container_mode', !form.container_mode)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||
|
||||
Reference in New Issue
Block a user