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,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():
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user