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:
Pouzor
2026-03-07 14:15:08 +01:00
parent 9eba62c5b5
commit 4fb9a8ee45
11 changed files with 190 additions and 30 deletions
+5
View File
@@ -1,3 +1,5 @@
from contextlib import suppress
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
@@ -18,6 +20,9 @@ class Base(DeclarativeBase):
async def init_db() -> None: async def init_db() -> None:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Add container_mode column if not present (existing databases)
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
async def get_db(): async def get_db():
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base from app.db.database import Base
@@ -33,6 +33,7 @@ class Node(Base):
pos_x: Mapped[float] = mapped_column(Float, default=0) pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0) pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+1
View File
@@ -20,6 +20,7 @@ class NodeSave(BaseModel):
services: list[Any] = [] services: list[Any] = []
notes: str | None = None notes: str | None = None
parent_id: str | None = None parent_id: str | None = None
container_mode: bool = False
pos_x: float = 0 pos_x: float = 0
pos_y: float = 0 pos_y: float = 0
+2
View File
@@ -19,6 +19,7 @@ class NodeBase(BaseModel):
pos_x: float = 0 pos_x: float = 0
pos_y: float = 0 pos_y: float = 0
parent_id: str | None = None parent_id: str | None = None
container_mode: bool = False
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
@@ -39,6 +40,7 @@ class NodeUpdate(BaseModel):
notes: str | None = None notes: str | None = None
pos_x: float | None = None pos_x: float | None = None
pos_y: float | None = None pos_y: float | None = None
container_mode: bool | None = None
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
+54 -12
View File
@@ -1,5 +1,5 @@
import { useEffect, useCallback, useRef, useState } from 'react' import { useEffect, useCallback, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection } from '@xyflow/react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react' import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout' import { applyDagreLayout } from '@/utils/layout'
import { exportToPng } from '@/utils/export' import { exportToPng } from '@/utils/export'
@@ -22,7 +22,7 @@ import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
export default function App() { export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes, edges } = useCanvasStore() const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
@@ -31,6 +31,7 @@ export default function App() {
const [addNodeOpen, setAddNodeOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null) const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null) const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false) const [scanConfigOpen, setScanConfigOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope
@@ -50,6 +51,7 @@ export default function App() {
services: n.data.services ?? [], services: n.data.services ?? [],
notes: n.data.notes ?? null, notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null, parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
pos_x: n.position.x, pos_x: n.position.x,
pos_y: n.position.y, pos_y: n.position.y,
})) }))
@@ -81,14 +83,23 @@ export default function App() {
.then((res) => { .then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) { if (apiNodes.length > 0) {
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => ({ // Build a map of proxmox container mode to know if children should be nested
id: n.id, const proxmoxContainerMap = new Map<string, boolean>(
type: n.type, apiNodes
position: { x: n.pos_x, y: n.pos_y }, .filter((n: NodeData & { id: string }) => n.type === 'proxmox')
data: n, .map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}), )
...(n.type === 'proxmox' ? { width: 300, height: 200 } : {}), const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => {
})) const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: n,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === 'proxmox' && n.container_mode !== false ? { width: 300, height: 200 } : {}),
}
})
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({ const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string }) => ({
id: e.id, id: e.id,
source: e.source, source: e.source,
@@ -144,8 +155,12 @@ export default function App() {
const handleUpdateNode = useCallback((data: Partial<NodeData>) => { const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
if (!editNodeId) return if (!editNodeId) return
updateNode(editNodeId, data) updateNode(editNodeId, data)
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
setProxmoxContainerMode(editNodeId, data.container_mode)
}
setEditNodeId(null) setEditNodeId(null)
}, [editNodeId, updateNode]) }, [editNodeId, updateNode, setProxmoxContainerMode])
const handleAutoLayout = useCallback(() => { const handleAutoLayout = useCallback(() => {
const laid = applyDagreLayout(nodes, edges) const laid = applyDagreLayout(nodes, edges)
@@ -174,7 +189,24 @@ export default function App() {
setPendingConnection(null) setPendingConnection(null)
}, [pendingConnection, onConnect]) }, [pendingConnection, onConnect])
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
setEditEdgeId(edge.id)
}, [])
const handleEdgeUpdate = useCallback((data: EdgeData) => {
if (!editEdgeId) return
updateEdge(editEdgeId, data)
setEditEdgeId(null)
}, [editEdgeId, updateEdge])
const handleEdgeDelete = useCallback(() => {
if (!editEdgeId) return
deleteEdge(editEdgeId)
setEditEdgeId(null)
}, [editEdgeId, deleteEdge])
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!isAuthenticated) return <LoginPage /> if (!isAuthenticated) return <LoginPage />
@@ -195,7 +227,7 @@ export default function App() {
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} /> <CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
</div> </div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />} {selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
</div> </div>
@@ -227,6 +259,16 @@ export default function App() {
onSubmit={handleEdgeConfirm} onSubmit={handleEdgeConfirm}
/> />
<EdgeModal
key={editEdgeId ?? 'edge-edit'}
open={!!editEdgeId}
onClose={() => setEditEdgeId(null)}
onSubmit={handleEdgeUpdate}
onDelete={handleEdgeDelete}
initial={editEdge?.data}
title="Edit Link"
/>
<ScanConfigModal <ScanConfigModal
open={scanConfigOpen} open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)} onClose={() => setScanConfigOpen(false)}
@@ -1,4 +1,4 @@
import { useCallback } from 'react' import { useCallback, useMemo } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -17,9 +17,10 @@ import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps { interface CanvasContainerProps {
onConnect?: (connection: Connection) => void onConnect?: (connection: Connection) => void
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
} }
export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
const { const {
nodes, edges, nodes, edges,
onNodesChange, onEdgesChange, onNodesChange, onEdgesChange,
@@ -34,16 +35,38 @@ export function CanvasContainer({ onConnect: onConnectProp }: CanvasContainerPro
setSelectedNode(null) setSelectedNode(null)
}, [setSelectedNode]) }, [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 ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges as Edge<EdgeData>[]} edges={visibleEdges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnectProp} onConnect={onConnectProp}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onPaneClick={onPaneClick} onPaneClick={onPaneClick}
onEdgeDoubleClick={handleEdgeDoubleClick}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
snapToGrid snapToGrid
@@ -1,6 +1,7 @@
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types' import type { NodeData, NodeStatus } from '@/types'
import { BaseNode } from './BaseNode'
const STATUS_COLORS: Record<NodeStatus, string> = { const STATUS_COLORS: Record<NodeStatus, string> = {
online: '#39d353', online: '#39d353',
@@ -11,7 +12,14 @@ const STATUS_COLORS: Record<NodeStatus, string> = {
const GLOW = '#ff6e00' 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 statusColor = STATUS_COLORS[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
+25 -13
View File
@@ -12,12 +12,15 @@ interface EdgeModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onSubmit: (data: EdgeData) => void onSubmit: (data: EdgeData) => void
onDelete?: () => void
initial?: Partial<EdgeData>
title?: string
} }
export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) { export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
const [type, setType] = useState<EdgeType>('ethernet') const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
const [label, setLabel] = useState('') const [label, setLabel] = useState(initial?.label ?? '')
const [vlanId, setVlanId] = useState('') const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -27,16 +30,18 @@ export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
}) })
onClose() onClose()
setType('ethernet') }
setLabel('')
setVlanId('') const handleDelete = () => {
onDelete?.()
onClose()
} }
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-xs"> <DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-xs">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm font-semibold">Connect Nodes</DialogTitle> <DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-3 mt-2"> <form onSubmit={handleSubmit} className="flex flex-col gap-3 mt-2">
@@ -79,11 +84,18 @@ export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) {
/> />
</div> </div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-between gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button> {onDelete ? (
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"> <Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
Connect Delete
</Button> </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> </div>
</form> </form>
</DialogContent> </DialogContent>
@@ -18,6 +18,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
status: 'unknown', status: 'unknown',
check_method: 'ping', check_method: 'ping',
services: [], services: [],
container_mode: true,
} }
interface NodeModalProps { interface NodeModalProps {
@@ -153,6 +154,29 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div> </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 */} {/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label> <Label className="text-xs text-muted-foreground">Notes</Label>
+41
View File
@@ -24,6 +24,9 @@ interface CanvasState {
addNode: (node: Node<NodeData>) => void addNode: (node: Node<NodeData>) => void
updateNode: (id: string, data: Partial<NodeData>) => void updateNode: (id: string, data: Partial<NodeData>) => void
deleteNode: (id: string) => void deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void
deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
markSaved: () => void markSaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
} }
@@ -83,6 +86,44 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: true, hasUnsavedChanges: true,
})), })),
updateEdge: (id, data) =>
set((state) => ({
edges: state.edges.map((e) =>
e.id === id ? { ...e, type: data.type ?? e.type, data: { ...e.data, ...data } as EdgeData } : e
),
hasUnsavedChanges: true,
})),
deleteEdge: (id) =>
set((state) => ({
edges: state.edges.filter((e) => e.id !== id),
hasUnsavedChanges: true,
})),
setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => {
let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
return enabled
? { ...withMode, width: 300, height: 200 }
: { ...withMode, width: undefined, height: undefined }
}
if (n.data.parent_id === proxmoxId) {
return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined }
}
return n
})
if (enabled) {
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children]
}
return { nodes, hasUnsavedChanges: true }
}),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
loadCanvas: (nodes, edges) => { loadCanvas: (nodes, edges) => {
+1
View File
@@ -40,6 +40,7 @@ export interface NodeData extends Record<string, unknown> {
response_time_ms?: number response_time_ms?: number
notes?: string notes?: string
parent_id?: string parent_id?: string
container_mode?: boolean
} }
export interface EdgeData extends Record<string, unknown> { export interface EdgeData extends Record<string, unknown> {