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.orm import DeclarativeBase
@@ -18,6 +20,9 @@ class Base(DeclarativeBase):
async def init_db() -> None:
async with engine.begin() as conn:
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():
+2 -1
View File
@@ -1,7 +1,7 @@
import uuid
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 app.db.database import Base
@@ -33,6 +33,7 @@ class Node(Base):
pos_x: 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"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+1
View File
@@ -20,6 +20,7 @@ class NodeSave(BaseModel):
services: list[Any] = []
notes: str | None = None
parent_id: str | None = None
container_mode: bool = False
pos_x: float = 0
pos_y: float = 0
+2
View File
@@ -19,6 +19,7 @@ class NodeBase(BaseModel):
pos_x: float = 0
pos_y: float = 0
parent_id: str | None = None
container_mode: bool = False
class NodeCreate(NodeBase):
@@ -39,6 +40,7 @@ class NodeUpdate(BaseModel):
notes: str | None = None
pos_x: float | None = None
pos_y: float | None = None
container_mode: bool | None = None
class NodeResponse(NodeBase):
+54 -12
View File
@@ -1,5 +1,5 @@
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 { applyDagreLayout } from '@/utils/layout'
import { exportToPng } from '@/utils/export'
@@ -22,7 +22,7 @@ import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types'
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 { isAuthenticated } = useAuthStore()
@@ -31,6 +31,7 @@ export default function App() {
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope
@@ -50,6 +51,7 @@ export default function App() {
services: n.data.services ?? [],
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
pos_x: n.position.x,
pos_y: n.position.y,
}))
@@ -81,14 +83,23 @@ export default function App() {
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => ({
id: n.id,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: n,
...(n.parent_id ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === 'proxmox' ? { width: 300, height: 200 } : {}),
}))
// Build a map of proxmox container mode to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>(
apiNodes
.filter((n: NodeData & { id: string }) => n.type === 'proxmox')
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
)
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 }) => ({
id: e.id,
source: e.source,
@@ -144,8 +155,12 @@ export default function App() {
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
if (!editNodeId) return
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)
}, [editNodeId, updateNode])
}, [editNodeId, updateNode, setProxmoxContainerMode])
const handleAutoLayout = useCallback(() => {
const laid = applyDagreLayout(nodes, edges)
@@ -174,7 +189,24 @@ export default function App() {
setPendingConnection(null)
}, [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 editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!isAuthenticated) return <LoginPage />
@@ -195,7 +227,7 @@ export default function App() {
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} />
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
</div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
</div>
@@ -227,6 +259,16 @@ export default function App() {
onSubmit={handleEdgeConfirm}
/>
<EdgeModal
key={editEdgeId ?? 'edge-edit'}
open={!!editEdgeId}
onClose={() => setEditEdgeId(null)}
onSubmit={handleEdgeUpdate}
onDelete={handleEdgeDelete}
initial={editEdge?.data}
title="Edit Link"
/>
<ScanConfigModal
open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)}
@@ -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'
+25 -13
View File
@@ -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>
+41
View File
@@ -24,6 +24,9 @@ interface CanvasState {
addNode: (node: Node<NodeData>) => void
updateNode: (id: string, data: Partial<NodeData>) => void
deleteNode: (id: string) => void
updateEdge: (id: string, data: Partial<EdgeData>) => void
deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
markSaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
}
@@ -83,6 +86,44 @@ export const useCanvasStore = create<CanvasState>((set) => ({
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 }),
loadCanvas: (nodes, edges) => {
+1
View File
@@ -40,6 +40,7 @@ export interface NodeData extends Record<string, unknown> {
response_time_ms?: number
notes?: string
parent_id?: string
container_mode?: boolean
}
export interface EdgeData extends Record<string, unknown> {