diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 323fbf9..27da919 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -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(): diff --git a/backend/app/db/models.py b/backend/app/db/models.py index e90320f..5dff858 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index ee503f3..9429f62 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -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 diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 4f6a6b7..65592f4 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -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): diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff4b699..e42b669 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null) const { isAuthenticated } = useAuthStore() @@ -31,6 +31,7 @@ export default function App() { const [addNodeOpen, setAddNodeOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) + const [editEdgeId, setEditEdgeId] = useState(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( + 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) => { 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) => { + 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 @@ -195,7 +227,7 @@ export default function App() { />
- +
{selectedNodeId && }
@@ -227,6 +259,16 @@ export default function App() { onSubmit={handleEdgeConfirm} /> + setEditEdgeId(null)} + onSubmit={handleEdgeUpdate} + onDelete={handleEdgeDelete} + initial={editEdge?.data} + title="Edit Link" + /> + setScanConfigOpen(false)} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 893b8e7..bd29964 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -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) => 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) => { + 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[]).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 (
[]} + edges={visibleEdges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnectProp} onNodeClick={onNodeClick} onPaneClick={onPaneClick} + onEdgeDoubleClick={handleEdgeDoubleClick} nodeTypes={nodeTypes} edgeTypes={edgeTypes} snapToGrid diff --git a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx index 2d744e7..51cf764 100644 --- a/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx +++ b/frontend/src/components/canvas/nodes/ProxmoxGroupNode.tsx @@ -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 = { online: '#39d353', @@ -11,7 +12,14 @@ const STATUS_COLORS: Record = { const GLOW = '#ff6e00' -export function ProxmoxGroupNode({ data, selected }: NodeProps>) { +export function ProxmoxGroupNode(props: NodeProps>) { + const { data, selected } = props + + // Render as a regular node when container mode is disabled + if (data.container_mode === false) { + return + } + const statusColor = STATUS_COLORS[data.status] const isOnline = data.status === 'online' diff --git a/frontend/src/components/modals/EdgeModal.tsx b/frontend/src/components/modals/EdgeModal.tsx index 4295426..cfb6a71 100644 --- a/frontend/src/components/modals/EdgeModal.tsx +++ b/frontend/src/components/modals/EdgeModal.tsx @@ -12,12 +12,15 @@ interface EdgeModalProps { open: boolean onClose: () => void onSubmit: (data: EdgeData) => void + onDelete?: () => void + initial?: Partial + title?: string } -export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) { - const [type, setType] = useState('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(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 ( !o && onClose()}> - Connect Nodes + {title}
@@ -79,11 +84,18 @@ export function EdgeModal({ open, onClose, onSubmit }: EdgeModalProps) { />
-
- - +
+ {onDelete ? ( + + ) : } +
+ + +
diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index a060b91..7713591 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -18,6 +18,7 @@ const DEFAULT_DATA: Partial = { 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'
)} + {/* Container mode (proxmox only) */} + {form.type === 'proxmox' && ( +
+
+ + Show VM/LXC nodes nested inside +
+ +
+ )} + {/* Notes */}
diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index f298366..b54965c 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -24,6 +24,9 @@ interface CanvasState { addNode: (node: Node) => void updateNode: (id: string, data: Partial) => void deleteNode: (id: string) => void + updateEdge: (id: string, data: Partial) => void + deleteEdge: (id: string) => void + setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void markSaved: () => void loadCanvas: (nodes: Node[], edges: Edge[]) => void } @@ -83,6 +86,44 @@ export const useCanvasStore = create((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) => { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f70050b..a3c9433 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -40,6 +40,7 @@ export interface NodeData extends Record { response_time_ms?: number notes?: string parent_id?: string + container_mode?: boolean } export interface EdgeData extends Record {