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..81c871d
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+1.10.0
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/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/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/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/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/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/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",
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/__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__/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/__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/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx
index 8b95b7e..b114055 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> {
@@ -43,7 +43,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
return (
{/* Main row */}
-
+
{/* Icon */}
{data.label}
- {data.ip && (
+ {data.ip && splitIps(data.ip).map((ip) => (
- {hideIp ? maskIp(data.ip) : data.ip}
+ {hideIp ? maskIp(ip) : ip}
- )}
+ ))}
@@ -114,14 +115,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
{visibleProperties && visibleProperties.length > 0 && (
<>
-
+
{visibleProperties.map((prop) => {
const Icon = resolvePropertyIcon(prop.icon)
return (
-
+
{Icon && }
{prop.key}
- · {prop.value}
+ · {prop.value}
)
})}
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)
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/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())
+ })
+})
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/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 } : {}),
}
}
diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts
index 31b8fe9..8dfa9f8 100644
--- a/frontend/src/utils/maskIp.ts
+++ b/frontend/src/utils/maskIp.ts
@@ -1,15 +1,45 @@
/**
- * 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.
*/
-export function maskIp(ip: string): string {
- const parts = ip.split('.')
+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 ip
+ return trimmed
}
-export function primaryIp(ip: string): string {
- if (!ip?.trim()) return ''
- return ip.split(',')[0].trim()
+/**
+ * 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 {
+ 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] ?? ''
}
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: {