feat: resizable nodes with width/height persistence
Add NodeResizer to BaseNode so users can drag corners to resize any node. Persist width/height through the full stack: DB model, schemas, canvas save/load route, and migration for existing databases. Add tests covering save, update, clear, and load of node dimensions.
This commit is contained in:
@@ -52,6 +52,10 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ class Node(Base):
|
|||||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
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)
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class NodeSave(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ class NodeBase(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
@@ -56,6 +58,8 @@ class NodeUpdate(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool | None = None
|
show_hardware: bool | None = None
|
||||||
|
width: float | None = None
|
||||||
|
height: float | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -191,3 +191,49 @@ async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient
|
|||||||
node = canvas["nodes"][0]
|
node = canvas["nodes"][0]
|
||||||
assert node["cpu_count"] is None
|
assert node["cpu_count"] is None
|
||||||
assert node["ram_gb"] is None
|
assert node["ram_gb"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── node width / height (resizable nodes) ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(width=320.0, height=180.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["width"] == 320.0
|
||||||
|
assert node["height"] == 180.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_dimensions_default_null(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["width"] is None
|
||||||
|
assert canvas["nodes"][0]["height"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(width=140.0, height=50.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_resized = {**n1, "width": 280.0, "height": 120.0}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
node = canvas["nodes"][0]
|
||||||
|
assert node["width"] == 280.0
|
||||||
|
assert node["height"] == 120.0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(width=300.0, height=200.0)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
n1_cleared = {**n1, "width": None, "height": None}
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["width"] is None
|
||||||
|
assert canvas["nodes"][0]["height"] is None
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -18,7 +18,7 @@ function formatStorage(gb: number): string {
|
|||||||
return `${gb} GB`
|
return `${gb} GB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
@@ -43,8 +43,17 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
|
width: width ? '100%' : undefined,
|
||||||
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={140}
|
||||||
|
minHeight={50}
|
||||||
|
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
|
||||||
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||||
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Top}
|
position={Position.Top}
|
||||||
@@ -69,7 +78,7 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
{/* Label + IP */}
|
{/* Label + IP */}
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<div
|
<div
|
||||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
className="text-xs font-medium leading-tight truncate"
|
||||||
style={{ color: theme.colors.nodeLabelColor }}
|
style={{ color: theme.colors.nodeLabelColor }}
|
||||||
title={data.label}
|
title={data.label}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -328,4 +328,40 @@ describe('canvasStore', () => {
|
|||||||
useCanvasStore.getState().pasteNodes()
|
useCanvasStore.getState().pasteNodes()
|
||||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Node resizing (width / height) ---
|
||||||
|
|
||||||
|
it('addNode preserves explicit width and height', () => {
|
||||||
|
const node: Node<NodeData> = { ...makeNode('n1'), width: 280, height: 120 }
|
||||||
|
useCanvasStore.getState().addNode(node)
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(stored?.width).toBe(280)
|
||||||
|
expect(stored?.height).toBe(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('onNodesChange dimensions change updates width and height', () => {
|
||||||
|
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
useCanvasStore.getState().onNodesChange([
|
||||||
|
{ type: 'dimensions', id: 'n1', dimensions: { width: 320, height: 180 }, resizing: true },
|
||||||
|
])
|
||||||
|
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(node?.measured?.width ?? node?.width).toBeDefined()
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadCanvas preserves width and height on resized nodes', () => {
|
||||||
|
const resized: Node<NodeData> = { ...makeNode('n1'), width: 300, height: 160 }
|
||||||
|
useCanvasStore.getState().loadCanvas([resized], [])
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(stored?.width).toBe(300)
|
||||||
|
expect(stored?.height).toBe(160)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loadCanvas preserves undefined width/height for default-sized nodes', () => {
|
||||||
|
useCanvasStore.getState().loadCanvas([makeNode('n1')], [])
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(stored?.width).toBeUndefined()
|
||||||
|
expect(stored?.height).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user