From 0019c086cfead414522ee701ed415c5b96a7b651 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 01:21:09 +0200 Subject: [PATCH 1/9] feat: automatic DB backup before migrations using VERSION file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VERSION file at repo root as single source of truth for app version - frontend/vite.config.ts reads VERSION file instead of package.json - backend config.py exposes APP_VERSION read from VERSION (dev) or /app/VERSION (Docker) - database.py backs up DB to homelab.db.back-{version} before running migrations (skipped if DB doesn't exist or backup already exists — fully idempotent) - Dockerfile.backend and Dockerfile.frontend copy VERSION into the image - Add test_db_backup.py with 4 tests covering create/skip/idempotent/version cases --- Dockerfile.backend | 1 + Dockerfile.frontend | 1 + VERSION | 1 + backend/app/core/config.py | 11 +++++++ backend/app/db/database.py | 21 +++++++++++- backend/tests/test_db_backup.py | 57 +++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 6 ++-- 7 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 VERSION create mode 100644 backend/tests/test_db_backup.py diff --git a/Dockerfile.backend b/Dockerfile.backend index bb768ae..82bb73f 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -9,6 +9,7 @@ COPY backend/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY backend/ . +COPY VERSION /app/VERSION # Create data directory (volume mount point) RUN mkdir -p /app/data diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 9644afa..745c359 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -12,6 +12,7 @@ COPY frontend/package*.json ./ RUN npm ci COPY frontend/ . +COPY VERSION ../VERSION RUN npm run build # Stage 2: serve diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..2e0e38c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.9 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 749382a..481b7bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict logger = logging.getLogger(__name__) +def _read_version() -> str: + for candidate in [ + Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev) + Path("/app/VERSION"), # Docker image + ]: + if candidate.exists(): + return candidate.read_text().strip() + return "unknown" + +APP_VERSION = _read_version() + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") diff --git a/backend/app/db/database.py b/backend/app/db/database.py index 3831ef4..f7b3ac2 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -1,3 +1,5 @@ +import logging +import shutil from collections.abc import AsyncGenerator from contextlib import suppress from pathlib import Path @@ -6,7 +8,9 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase -from app.core.config import settings +from app.core.config import APP_VERSION, settings + +logger = logging.getLogger(__name__) # Ensure the data directory exists before SQLite tries to open the file Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True) @@ -23,7 +27,22 @@ class Base(DeclarativeBase): pass +def _backup_db() -> None: + db_path = Path(settings.sqlite_path) + if not db_path.exists(): + return + backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}") + if backup_path.exists(): + return + try: + shutil.copy2(db_path, backup_path) + logger.info("DB backup created: %s", backup_path.name) + except OSError: + logger.warning("Could not create DB backup at %s", backup_path) + + async def init_db() -> None: + _backup_db() async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) # Add columns introduced after initial schema (idempotent) diff --git a/backend/tests/test_db_backup.py b/backend/tests/test_db_backup.py new file mode 100644 index 0000000..ac54e92 --- /dev/null +++ b/backend/tests/test_db_backup.py @@ -0,0 +1,57 @@ +""" +Tests for automatic DB backup before migrations. +""" +import os + +os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production") + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from app.db.database import _backup_db + + +@pytest.fixture() +def tmp_db(tmp_path: Path): + db = tmp_path / "homelab.db" + db.write_bytes(b"SQLite placeholder") + return db + + +def test_backup_created_when_db_exists(tmp_db: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "1.9"): + mock_settings.sqlite_path = str(tmp_db) + _backup_db() + backup = tmp_db.parent / "homelab.db.back-1.9" + assert backup.exists() + assert backup.read_bytes() == b"SQLite placeholder" + + +def test_backup_skipped_when_db_missing(tmp_path: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "1.9"): + mock_settings.sqlite_path = str(tmp_path / "nonexistent.db") + _backup_db() + assert not any(tmp_path.glob("*.back-*")) + + +def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "1.9"): + mock_settings.sqlite_path = str(tmp_db) + _backup_db() + backup = tmp_db.parent / "homelab.db.back-1.9" + backup.write_bytes(b"original backup") + _backup_db() + assert backup.read_bytes() == b"original backup" + + +def test_backup_version_in_filename(tmp_db: Path): + with patch("app.db.database.settings") as mock_settings, \ + patch("app.db.database.APP_VERSION", "2.0"): + mock_settings.sqlite_path = str(tmp_db) + _backup_db() + assert (tmp_db.parent / "homelab.db.back-2.0").exists() diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c7f1f73..fbca3fc 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,12 +1,14 @@ +import fs from 'fs' import path from 'path' import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' -import pkg from './package.json' + +const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim() export default defineConfig({ define: { - __APP_VERSION__: JSON.stringify(pkg.version), + __APP_VERSION__: JSON.stringify(appVersion), }, plugins: [react(), tailwindcss()], resolve: { From ce5fc785e12f6c28352f429cb0a48c0fc9dd0315 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 01:29:23 +0200 Subject: [PATCH 2/9] chore: bump version to 1.10.0 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 2e0e38c..81c871d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9 +1.10.0 From 6c9974b357aefde4a2cd852ef56ced97288885b7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 01:37:51 +0200 Subject: [PATCH 3/9] bump version 1.10 --- backend/data/.gitignore | 1 + frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/data/.gitignore b/backend/data/.gitignore index ec5328c..8483db4 100644 --- a/backend/data/.gitignore +++ b/backend/data/.gitignore @@ -2,3 +2,4 @@ *.db-shm *.db-wal scan_config.json +homelab.db.* diff --git a/frontend/package.json b/frontend/package.json index d8ac74a..e65b80e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.9.0", + "version": "1.10.0", "type": "module", "scripts": { "dev": "vite", From ef96cafcc8a85e55aa1d9c4ad88543bf9dd86c9d Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 02:02:14 +0200 Subject: [PATCH 4/9] feat: IPv6 support and multi-IP per node (closes #60) - maskIp handles IPv6 addresses (masks second and last group) - maskIp handles comma-separated IP strings (masks each address) - Add splitIps() helper to parse comma-separated IP field - Add primaryIp() helper used by status checker (first IP wins) - BaseNode renders each IP on its own line when comma-separated - NodeModal placeholder shows comma-separated example - Backend status_checker uses only first IP for connectivity checks - Expand maskIp test suite: IPv6, comma-separated, splitIps, primaryIp --- backend/app/services/status_checker.py | 4 +- .../canvas/__tests__/BaseNode.test.tsx | 1 + .../src/components/canvas/nodes/BaseNode.tsx | 11 ++-- frontend/src/components/modals/NodeModal.tsx | 4 +- .../modals/__tests__/NodeModal.test.tsx | 4 +- frontend/src/utils/__tests__/maskIp.test.ts | 59 ++++++++++++++++++- frontend/src/utils/maskIp.ts | 47 +++++++++++++-- 7 files changed, 111 insertions(+), 19 deletions(-) diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py index 85b939e..7f150e8 100644 --- a/backend/app/services/status_checker.py +++ b/backend/app/services/status_checker.py @@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d if check_method == "none": return {"status": "online", "response_time_ms": None} - host = target or ip + # Use only the first IP when the field contains comma-separated addresses + raw_ip = ip.split(",")[0].strip() if ip else None + host = target or raw_ip if not host: return {"status": "unknown", "response_time_ms": None} diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index 71064df..3ffb576 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -48,6 +48,7 @@ vi.mock('@/utils/nodeIcons', () => ({ vi.mock('@/utils/maskIp', () => ({ maskIp: (ip: string) => ip, + splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [], })) vi.mock('@/utils/propertyIcons', () => ({ diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 8b95b7e..4dae035 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -8,7 +8,7 @@ import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' -import { maskIp } from '@/utils/maskIp' +import { maskIp, splitIps } from '@/utils/maskIp' import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils' interface BaseNodeProps extends NodeProps> { @@ -98,15 +98,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: > {data.label} - {data.ip && ( + {data.ip && splitIps(data.ip).map((ip) => (
- {hideIp ? maskIp(data.ip) : data.ip} + {hideIp ? maskIp(ip) : ip}
- )} + ))} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index c39f078..5ed1070 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -209,11 +209,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {/* IP */}
- + set('ip', e.target.value)} - placeholder="192.168.1.x" + placeholder="192.168.1.x, 2001:db8::1" className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" />
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 4b24085..ffc0c2b 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -72,7 +72,7 @@ describe('NodeModal', () => { renderModal({ initial: BASE }) expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server') expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('server.lan') - expect((screen.getByPlaceholderText('192.168.1.x') as HTMLInputElement).value).toBe('192.168.1.10') + expect((screen.getByPlaceholderText('192.168.1.x, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10') }) // ── Cancel ──────────────────────────────────────────────────────────── @@ -121,7 +121,7 @@ describe('NodeModal', () => { it('submits updated hostname, IP and notes', () => { const { onSubmit } = renderModal({ initial: BASE }) fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } }) - fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { target: { value: '10.0.0.1' } }) + fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } }) fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } }) fireEvent.click(screen.getByRole('button', { name: 'Add' })) const data = onSubmit.mock.calls[0][0] as Partial diff --git a/frontend/src/utils/__tests__/maskIp.test.ts b/frontend/src/utils/__tests__/maskIp.test.ts index 1f18cb5..d5f0a33 100644 --- a/frontend/src/utils/__tests__/maskIp.test.ts +++ b/frontend/src/utils/__tests__/maskIp.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest' -import { maskIp } from '../maskIp' +import { maskIp, splitIps, primaryIp } from '../maskIp' describe('maskIp', () => { + // IPv4 it('masks last two octets of a standard IPv4', () => { expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX') }) @@ -11,9 +12,61 @@ describe('maskIp', () => { expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX') }) - it('passes through non-IPv4 strings unchanged', () => { + // IPv6 + it('masks second group and last group of an IPv6 address', () => { + expect(maskIp('2001:db8::1')).toBe('2001:XX::XX') + }) + + it('masks a full IPv6 address', () => { + expect(maskIp('fe80:0000:0000:0000:0202:b3ff:fe1e:8329')).toBe('fe80:XX:0000:0000:0202:b3ff:fe1e:XX') + }) + + it('masks loopback IPv6', () => { + // ::1 splits into ['', '', '1'] — groups[1] and last are masked + expect(maskIp('::1')).toBe(':XX:XX') + }) + + // Comma-separated + it('masks all IPs in a comma-separated string', () => { + expect(maskIp('192.168.1.1, 2001:db8::1')).toBe('192.168.XX.XX, 2001:XX::XX') + }) + + it('handles comma-separated without spaces', () => { + expect(maskIp('10.0.0.1,10.0.0.2')).toBe('10.0.XX.XX, 10.0.XX.XX') + }) + + // Edge cases + it('passes through non-IP strings unchanged', () => { expect(maskIp('hostname')).toBe('hostname') - expect(maskIp('fe80::1')).toBe('fe80::1') expect(maskIp('')).toBe('') }) }) + +describe('splitIps', () => { + it('returns array of trimmed IPs', () => { + expect(splitIps('192.168.1.1, 2001:db8::1')).toEqual(['192.168.1.1', '2001:db8::1']) + }) + + it('returns single-element array for single IP', () => { + expect(splitIps('10.0.0.1')).toEqual(['10.0.0.1']) + }) + + it('returns empty array for empty string', () => { + expect(splitIps('')).toEqual([]) + expect(splitIps(' ')).toEqual([]) + }) +}) + +describe('primaryIp', () => { + it('returns first IP from comma-separated string', () => { + expect(primaryIp('192.168.1.1, 2001:db8::1')).toBe('192.168.1.1') + }) + + it('returns the only IP when single', () => { + expect(primaryIp('10.0.0.1')).toBe('10.0.0.1') + }) + + it('returns empty string for empty input', () => { + expect(primaryIp('')).toBe('') + }) +}) diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts index 31b8fe9..dec2969 100644 --- a/frontend/src/utils/maskIp.ts +++ b/frontend/src/utils/maskIp.ts @@ -1,12 +1,47 @@ /** - * Mask the last two octets of an IPv4 address. - * e.g. "192.168.1.115" → "192.168.XX.XX" - * Non-IPv4 strings are returned unchanged. + * Mask a single IP address: + * - IPv4 "192.168.1.115" → "192.168.XX.XX" + * - IPv6 "2001:db8::1" → "2001:XX::XX" + * - Other strings returned unchanged. + */ +function maskSingle(ip: string): string { + const trimmed = ip.trim() + if (/^[\da-fA-F:]+$/.test(trimmed) && trimmed.includes(':')) { + const groups = trimmed.split(':') + if (groups.length >= 2) { + groups[1] = 'XX' + groups[groups.length - 1] = 'XX' + return groups.join(':') + } + } + const parts = trimmed.split('.') + if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX` + return trimmed +} + +/** + * Mask all IPs in a comma-separated string. + * e.g. "192.168.1.1, 2001:db8::1" → "192.168.XX.XX, 2001:XX::XX" */ export function maskIp(ip: string): string { - const parts = ip.split('.') - if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX` - return ip + if (!ip) return ip + return ip.split(',').map(maskSingle).join(', ') +} + +/** + * Split a comma-separated IP string into an array of trimmed values. + * Empty string returns []. + */ +export function splitIps(ip: string): string[] { + if (!ip?.trim()) return [] + return ip.split(',').map((s) => s.trim()).filter(Boolean) +} + +/** + * Return the first IP from a comma-separated string (used for status checks). + */ +export function primaryIp(ip: string): string { + return splitIps(ip)[0] ?? '' } export function primaryIp(ip: string): string { From 5ad5eba58c982c7b379fa2965db5a8b6da62e034 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 12:08:41 +0200 Subject: [PATCH 5/9] feat: add connection handles to zone nodes (closes #58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GroupRectNode now renders source+target handles on all four sides (top, right, bottom, left) using IDs zone-{side} / zone-{side}-t - Handles are hover-only: opacity 0 by default, fade in on mouse enter - Handle color matches the zone border color (respects custom_colors) - Zone↔zone and zone↔node connections both allowed; edge type picker (EdgeModal) opens on connect so user chooses ethernet/wifi/vlan/etc. - Add GroupRectNode.test.tsx: verifies 8 handles rendered (4 source + 4 target) - Fix @xyflow/react mocks in LiveView and CanvasContainer tests to include Position --- .../components/__tests__/LiveView.test.tsx | 2 + .../canvas/__tests__/CanvasContainer.test.tsx | 1 + .../canvas/__tests__/GroupRectNode.test.tsx | 77 +++++++++++++++++++ .../components/canvas/nodes/GroupRectNode.tsx | 31 +++++++- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx index f3c97de..603d1fa 100644 --- a/frontend/src/components/__tests__/LiveView.test.tsx +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({ Controls: () => null, BackgroundVariant: { Dots: 'dots' }, ConnectionMode: { Loose: 'loose' }, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, useReactFlow: () => ({ fitView: vi.fn() }), })) vi.mock('@xyflow/react/dist/style.css', () => ({})) @@ -143,6 +144,7 @@ const XYFLOW_MOCK = { Controls: () => null, BackgroundVariant: { Dots: 'dots' }, ConnectionMode: { Loose: 'loose' }, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, useReactFlow: () => ({ fitView: vi.fn() }), } diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx index 332e983..caf9ac8 100644 --- a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx +++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx @@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({ BackgroundVariant: { Dots: 'dots' }, ConnectionMode: { Loose: 'loose' }, SelectionMode: { Partial: 'partial' }, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, useReactFlow: () => ({ fitView: vi.fn() }), })) diff --git a/frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx new file mode 100644 index 0000000..f7db318 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/GroupRectNode.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { GroupRectNode } from '../nodes/GroupRectNode' +import type { NodeData } from '@/types' +import type { Node } from '@xyflow/react' + +vi.mock('@xyflow/react', () => ({ + Handle: ({ id, type }: { id: string; type: string }) =>
, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, + NodeResizer: () => null, +})) + +vi.mock('@/stores/canvasStore', () => ({ + useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) => + sel({ setEditingGroupRectId: vi.fn() }), +})) + +function makeNode(overrides: Partial = {}): Node { + return { + id: 'zone1', + type: 'groupRect', + position: { x: 0, y: 0 }, + data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides }, + } +} + +function renderZone(overrides: Partial = {}) { + const node = makeNode(overrides) + return render( + + ) +} + +describe('GroupRectNode — handles', () => { + it('renders source handles on all four sides', () => { + renderZone() + expect(screen.getByTestId('handle-zone-top')).toBeDefined() + expect(screen.getByTestId('handle-zone-right')).toBeDefined() + expect(screen.getByTestId('handle-zone-bottom')).toBeDefined() + expect(screen.getByTestId('handle-zone-left')).toBeDefined() + }) + + it('renders target handles on all four sides', () => { + renderZone() + expect(screen.getByTestId('handle-zone-top-t')).toBeDefined() + expect(screen.getByTestId('handle-zone-right-t')).toBeDefined() + expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined() + expect(screen.getByTestId('handle-zone-left-t')).toBeDefined() + }) + + it('renders 8 handles total (4 source + 4 target)', () => { + renderZone() + expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8) + }) +}) + +describe('GroupRectNode — label', () => { + it('renders inside label by default', () => { + renderZone({ label: 'DMZ' }) + expect(screen.getByText('DMZ')).toBeDefined() + }) + + it('renders no label when label is empty', () => { + renderZone({ label: '' }) + expect(screen.queryByText('DMZ')).toBeNull() + }) +}) diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index 33709db..574145e 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -1,4 +1,5 @@ -import { NodeResizer, type NodeProps, type Node } from '@xyflow/react' +import { useState } from 'react' +import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { useCanvasStore } from '@/stores/canvasStore' import type { NodeData, TextPosition } from '@/types' @@ -26,8 +27,16 @@ const POSITION_STYLES: Record = { 'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' }, } +const HANDLE_SIDES = [ + { id: 'zone-top', position: Position.Top }, + { id: 'zone-right', position: Position.Right }, + { id: 'zone-bottom', position: Position.Bottom }, + { id: 'zone-left', position: Position.Left }, +] as const + export function GroupRectNode({ id, data, selected }: NodeProps>) { const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId) + const [hovered, setHovered] = useState(false) const rc = data.custom_colors ?? {} const borderColor = rc.border ?? '#00d4ff' @@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) whiteSpace: 'pre-wrap', } + const handleStyle: React.CSSProperties = { + width: 10, + height: 10, + background: borderColor, + border: '2px solid #0d1117', + borderRadius: '50%', + opacity: hovered ? 1 : 0, + transition: 'opacity 0.15s', + } + return ( <> >) }} lineStyle={{ borderColor: 'transparent' }} /> + + {HANDLE_SIDES.map(({ id: hid, position }) => ( + + + + + ))} +
>) boxSizing: 'border-box', cursor: 'default', }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} onDoubleClick={(e) => { e.stopPropagation() setEditingGroupRectId(id) From 0193f933ceab5176633b0cb62023ae902fec722b Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 14:10:08 +0200 Subject: [PATCH 6/9] feat: bulk approve/hide pending devices (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: POST /scan/pending/bulk-approve and /scan/pending/bulk-hide endpoints (registered before dynamic routes to avoid conflict); bulk-approve response includes device_ids for frontend mapping - Frontend: PendingDevicesPanel gains per-row checkboxes, select-all, and a bulk action bar (Approve N / Hide N) that appears when ≥1 device is selected - Tests: 6 new backend API tests + 7 new frontend UI tests for bulk selection flows --- backend/app/api/routes/scan.py | 59 ++++++++++ backend/tests/test_scan.py | 98 ++++++++++++++++ frontend/src/api/client.ts | 2 + frontend/src/components/panels/Sidebar.tsx | 103 ++++++++++++++++- .../panels/__tests__/Sidebar.test.tsx | 106 ++++++++++++++++++ 5 files changed, 365 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 6f3dcb2..1e4c86c 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -17,6 +17,10 @@ from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.services.scanner import request_cancel, run_scan +class BulkActionRequest(BaseModel): + device_ids: list[str] + + class ScanConfig(BaseModel): ranges: list[str] @@ -99,6 +103,61 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c return list(result.scalars().all()) +@router.post("/pending/bulk-approve", response_model=dict) +async def bulk_approve_devices( + payload: BulkActionRequest, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> dict[str, Any]: + result = await db.execute( + select(PendingDevice).where( + PendingDevice.id.in_(payload.device_ids), + PendingDevice.status == "pending", + ) + ) + devices = result.scalars().all() + node_ids: list[str] = [] + for device in devices: + device.status = "approved" + node = Node( + label=device.hostname or device.ip, + type=device.suggested_type or "generic", + ip=device.ip, + hostname=device.hostname, + status="unknown", + services=device.services or [], + ) + db.add(node) + node_ids.append(node.id) + await db.commit() + approved_device_ids = [d.id for d in devices] + return { + "approved": len(node_ids), + "node_ids": node_ids, + "device_ids": approved_device_ids, + "skipped": len(payload.device_ids) - len(node_ids), + } + + +@router.post("/pending/bulk-hide", response_model=dict) +async def bulk_hide_devices( + payload: BulkActionRequest, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> dict[str, Any]: + result = await db.execute( + select(PendingDevice).where( + PendingDevice.id.in_(payload.device_ids), + PendingDevice.status == "pending", + ) + ) + devices = result.scalars().all() + for device in devices: + device.status = "hidden" + await db.commit() + return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)} + + @router.post("/pending/{device_id}/approve", response_model=dict) async def approve_device( device_id: str, diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 9540370..5e76d70 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -444,3 +444,101 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession # Services and hostname should be updated assert device.hostname == "myhost.lan" assert any(s["port"] == 8096 for s in device.services) + + +# --- Bulk approve --- + +@pytest.fixture +async def two_pending_devices(db_session): + devices = [] + for i in range(2): + d = PendingDevice( + id=str(uuid.uuid4()), + ip=f"192.168.1.{10 + i}", + mac=None, + hostname=f"host-{i}", + os=None, + services=[], + suggested_type="generic", + status="pending", + ) + db_session.add(d) + devices.append(d) + await db_session.commit() + for d in devices: + await db_session.refresh(d) + return devices + + +@pytest.mark.asyncio +async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["approved"] == 2 + assert len(data["node_ids"]) == 2 + assert len(data["device_ids"]) == 2 + assert data["skipped"] == 0 + # Pending list should now be empty + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert pending_res.json() == [] + + +@pytest.mark.asyncio +async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + # Approve first device individually first + await client.post( + f"/api/v1/scan/pending/{ids[0]}/approve", + json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []}, + headers=headers, + ) + # Bulk approve both — first one is already approved (not pending), should be skipped + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["approved"] == 1 + assert data["skipped"] == 1 + + +@pytest.mark.asyncio +async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}) + assert res.status_code == 401 + + +# --- Bulk hide --- + +@pytest.mark.asyncio +async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["hidden"] == 2 + assert data["skipped"] == 0 + # Should appear in hidden list + hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) + assert len(hidden_res.json()) == 2 + + +@pytest.mark.asyncio +async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + # Hide first device individually first + await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers) + # Bulk hide both — first is already hidden (not pending anymore) + res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["hidden"] == 1 + assert data["skipped"] == 1 + + +@pytest.mark.asyncio +async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}) + assert res.status_code == 401 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6f9d808..62aed11 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -60,6 +60,8 @@ export const scanApi = { approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), hide: (id: string) => api.post(`/scan/pending/${id}/hide`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), + bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }), + bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }), stop: (runId: string) => api.post(`/scan/${runId}/stop`), getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index eb77c46..a8f7688 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -165,9 +165,26 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(false) const [selected, setSelected] = useState(null) + const [checkedIds, setCheckedIds] = useState>(new Set()) const { addNode, scanEventTs } = useCanvasStore() const highlightRef = useRef(null) + const allChecked = devices.length > 0 && checkedIds.size === devices.length + const someChecked = checkedIds.size > 0 + + const toggleCheck = (id: string, e: React.MouseEvent) => { + e.stopPropagation() + setCheckedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + } + + const toggleAll = () => { + setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id))) + } + const load = useCallback(async () => { setLoading(true) try { @@ -184,12 +201,58 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: try { await scanApi.clearPending() setDevices([]) + setCheckedIds(new Set()) toast.success('Pending devices cleared') } catch { toast.error('Failed to clear pending devices') } } + const handleBulkApprove = async () => { + const ids = [...checkedIds] + try { + const res = await scanApi.bulkApprove(ids) + const deviceToNode: Record = {} + res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] }) + const approvedDevices = devices.filter((d) => ids.includes(d.id)) + approvedDevices.forEach((d, i) => { + const nodeId = deviceToNode[d.id] + if (!nodeId) return + addNode({ + id: nodeId, + type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, + position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, + data: { + label: d.hostname ?? d.ip, + type: (d.suggested_type ?? 'generic') as import('@/types').NodeType, + ip: d.ip, + hostname: d.hostname ?? undefined, + status: 'unknown' as const, + services: (d.services ?? []) as import('@/types').ServiceInfo[], + }, + }) + onNodeApproved(nodeId) + }) + setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) + setCheckedIds(new Set()) + toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`) + } catch { + toast.error('Failed to bulk approve devices') + } + } + + const handleBulkHide = async () => { + const ids = [...checkedIds] + try { + const res = await scanApi.bulkHide(ids) + setDevices((prev) => prev.filter((d) => !ids.includes(d.id))) + setCheckedIds(new Set()) + toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`) + } catch { + toast.error('Failed to bulk hide devices') + } + } + useEffect(() => { load() }, [load]) useEffect(() => { @@ -251,7 +314,19 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: <>
- Pending +
+ {devices.length > 0 && ( + { if (el) el.indeterminate = someChecked && !allChecked }} + onChange={toggleAll} + className="w-3 h-3 accent-[#00d4ff] cursor-pointer" + title="Select all" + /> + )} + Pending +
+ {someChecked && ( +
+ + +
+ )} {loading && } {!loading && devices.length === 0 && (

No pending devices

@@ -288,10 +379,16 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: key={d.id} ref={isHighlighted ? highlightRef : null} onClick={() => setSelected(d)} - className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`} + className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`} >
- + toggleCheck(d.id, e)} + onChange={() => {}} + className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0" + /> {title}
{showIpBelow && ( diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index ddfbbbb..a63d0ed 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -9,6 +9,9 @@ import type { NodeData } from '@/types' vi.mock('@/stores/canvasStore') +const mockBulkApprove = vi.fn() +const mockBulkHide = vi.fn() + vi.mock('@/api/client', () => ({ scanApi: { trigger: vi.fn().mockResolvedValue({}), @@ -16,6 +19,12 @@ vi.mock('@/api/client', () => ({ hidden: vi.fn().mockResolvedValue({ data: [] }), runs: vi.fn().mockResolvedValue({ data: [] }), stop: vi.fn().mockResolvedValue({}), + clearPending: vi.fn().mockResolvedValue({}), + approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }), + hide: vi.fn().mockResolvedValue({ data: { hidden: true } }), + ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }), + bulkApprove: (...args: unknown[]) => mockBulkApprove(...args), + bulkHide: (...args: unknown[]) => mockBulkHide(...args), }, settingsApi: { get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }), @@ -259,3 +268,100 @@ describe('Sidebar', () => { expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() }) }) + +// ── PendingDevicesPanel — bulk select ───────────────────────────────────────── + +const DEVICE_A = { + id: 'dev-a', + ip: '192.168.1.10', + hostname: 'host-a', + mac: null, + os: null, + services: [], + suggested_type: 'generic', + status: 'pending', + discovery_source: 'arp', +} + +const DEVICE_B = { + id: 'dev-b', + ip: '192.168.1.11', + hostname: 'host-b', + mac: null, + os: null, + services: [], + suggested_type: 'generic', + status: 'pending', + discovery_source: 'arp', +} + +describe('PendingDevicesPanel — bulk select', () => { + beforeEach(() => { + mockStore() + vi.clearAllMocks() + mockBulkApprove.mockResolvedValue({ + data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 }, + }) + mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } }) + }) + + async function renderWithDevices() { + const { scanApi } = await import('@/api/client') + vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never) + render() + await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument()) + } + + it('renders checkboxes for each device', async () => { + await renderWithDevices() + const checkboxes = screen.getAllByRole('checkbox') + // select-all + 2 device checkboxes + expect(checkboxes.length).toBe(3) + }) + + it('shows bulk action bar when a device is checked', async () => { + await renderWithDevices() + const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox') + fireEvent.click(firstDeviceCheckbox) + await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument()) + expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument() + }) + + it('hides bulk action bar when no device is checked', async () => { + await renderWithDevices() + expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument() + }) + + it('select-all checks all devices', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) + await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument()) + }) + + it('select-all unchecks all when all are selected', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) // select all + fireEvent.click(selectAll) // deselect all + await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()) + }) + + it('calls bulkApprove with checked ids and removes devices from list', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) + fireEvent.click(screen.getByText(/Approve \(2\)/)) + await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) + await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument()) + }) + + it('calls bulkHide with checked ids and removes devices from list', async () => { + await renderWithDevices() + const [selectAll] = screen.getAllByRole('checkbox') + fireEvent.click(selectAll) + fireEvent.click(screen.getByText(/Hide \(2\)/)) + await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b'])) + await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument()) + }) +}) From b5eb8d1b74dd151101979ac74f4f4718027fa42a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 22:30:50 +0200 Subject: [PATCH 7/9] fix: remove duplicate primaryIp export in maskIp.ts after rebase --- frontend/src/utils/maskIp.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts index dec2969..8dfa9f8 100644 --- a/frontend/src/utils/maskIp.ts +++ b/frontend/src/utils/maskIp.ts @@ -43,8 +43,3 @@ export function splitIps(ip: string): string[] { export function primaryIp(ip: string): string { return splitIps(ip)[0] ?? '' } - -export function primaryIp(ip: string): string { - if (!ip?.trim()) return '' - return ip.split(',')[0].trim() -} From fbfacec6dc3ce11f776ea9906d1da8bd6e140dbd Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 19 Apr 2026 22:58:10 +0200 Subject: [PATCH 8/9] fix: prevent node from expanding beyond resized width on reload --- frontend/src/components/canvas/nodes/BaseNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 4dae035..3c0f22c 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -43,7 +43,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: return (
Date: Sun, 19 Apr 2026 23:43:15 +0200 Subject: [PATCH 9/9] fix: prevent node width expansion when content overflows after resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox nodes with container_mode=false fell through both width conditions in deserializeApiNode and got no explicit width on reload, causing RF to auto-size to content width and ignoring the user's manual resize. - canvasSerializer: unified width restore logic — saved width applies to all node types; proxmox container_mode defaults (300x200) only kick in when no saved width exists - BaseNode: add overflow-hidden + min-w-0 to properties row so truncate actually clips long values instead of expanding the node --- frontend/src/components/canvas/nodes/BaseNode.tsx | 8 ++++---- frontend/src/utils/canvasSerializer.ts | 11 ++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index 3c0f22c..b114055 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -77,7 +77,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: {/* Main row */} -
+
{/* Icon */}
0 && ( <>
-
+
{visibleProperties.map((prop) => { const Icon = resolvePropertyIcon(prop.icon) return ( -
+
{Icon && } {prop.key} - · {prop.value} + · {prop.value}
) })} diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index 68b20d7..a0b0a43 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -102,8 +102,8 @@ export function serializeNode(n: Node): Record { disk_gb: n.data.disk_gb ?? null, show_hardware: n.data.show_hardware ?? false, properties: n.data.properties ?? [], - width: n.width ?? null, - height: n.height ?? null, + width: n.measured?.width ?? n.width ?? null, + height: n.measured?.height ?? n.height ?? null, bottom_handles: n.data.bottom_handles ?? 1, pos_x: n.position.x, pos_y: n.position.y, @@ -156,11 +156,8 @@ export function deserializeApiNode( position: { x: n.pos_x, y: n.pos_y }, data: n as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), - ...(n.type === 'proxmox' && n.container_mode !== false - ? { width: n.width ?? 300, height: n.height ?? 200 } - : {}), - ...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}), - ...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}), + ...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}), + ...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}), } }