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: {