diff --git a/Dockerfile.backend b/Dockerfile.backend index 86957ac..82bb73f 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -2,13 +2,14 @@ FROM python:3.13-slim WORKDIR /app -# Install nmap for network scanning + iputils-ping for ping-based status checks -RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/* +# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check +RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/* 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..4dae298 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.10.1 diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 6f3dcb2..5c77bf1 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] @@ -37,7 +41,15 @@ router = APIRouter() async def _background_scan(run_id: str, ranges: list[str]) -> None: async with AsyncSessionLocal() as db: - await run_scan(ranges, db, run_id) + try: + await run_scan(ranges, db, run_id) + except Exception: + logger.exception("Scan run %s failed unexpectedly", run_id) + await db.rollback() + run = await db.get(ScanRun, run_id) + if run and run.status == "running": + run.status = "failed" + await db.commit() @router.post("/trigger", response_model=ScanRunResponse) @@ -85,12 +97,10 @@ async def clear_pending( db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> dict[str, int]: - result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) - devices = result.scalars().all() - for device in devices: - await db.delete(device) + from sqlalchemy import delete as sa_delete + result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending")) await db.commit() - return {"deleted": len(devices)} + return {"deleted": result.rowcount} @router.get("/hidden", response_model=list[PendingDeviceResponse]) @@ -99,6 +109,63 @@ 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() + created_nodes: list[Node] = [] + 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) + created_nodes.append(node) + await db.flush() # populates node.id from Python-side default before reading + node_ids = [n.id for n in created_nodes] + approved_device_ids = [d.id for d in devices] + await db.commit() + 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, @@ -107,13 +174,24 @@ async def approve_device( _: str = Depends(get_current_user), ) -> dict[str, Any]: device = await db.get(PendingDevice, device_id) - if device: - device.status = "approved" - node = Node(**node_data.model_dump()) - db.add(node) - await db.commit() - return {"approved": True, "node_id": node.id} - return {"approved": False} + if not device: + raise HTTPException(status_code=404, detail="Device not found") + if device.status != "pending": + raise HTTPException(status_code=409, detail="Device already processed") + device.status = "approved" + node = Node( + label=node_data.label, + type=node_data.type, + ip=node_data.ip, + hostname=node_data.hostname, + status=node_data.status, + services=node_data.services or [], + ) + db.add(node) + await db.flush() + node_id = node.id + await db.commit() + return {"approved": True, "node_id": node_id} @router.post("/pending/{device_id}/hide") @@ -153,10 +231,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: @router.post("/config", response_model=ScanConfig) async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: + previous = settings.scanner_ranges + settings.scanner_ranges = payload.ranges try: - settings.scanner_ranges = payload.ranges settings.save_overrides() return payload except Exception as exc: + settings.scanner_ranges = previous logger.error("Failed to save scan config: %s", exc) raise HTTPException(status_code=500, detail="Failed to save scan config") from exc 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_canvas.py b/backend/tests/test_canvas.py index 7b25f25..3d7dadf 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head assert node["notes"] == "My NAS device" +async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict): + services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}] + n1 = node_payload(ip="192.168.1.50:8080", services=services) + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + canvas = (await client.get("/api/v1/canvas", headers=headers)).json() + assert canvas["nodes"][0]["services"] == services + + async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict): n1 = node_payload(check_method="ping", check_target="192.168.1.1") await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) 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..86ce554 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers): json=node_payload, headers=headers, ) - assert res.status_code == 200 - assert res.json()["approved"] is False + assert res.status_code == 404 # --- Hide device --- @@ -444,3 +443,102 @@ 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 all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs" + 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..8bf55e8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.9.0", + "version": "1.10.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fdd1b1c..1c83913 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { generateUUID } from '@/utils/uuid' import { generateMarkdownTable } from '@/utils/exportMarkdown' -import { exportToPng } from '@/utils/export' +import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' import { TooltipProvider } from '@/components/ui/tooltip' @@ -53,6 +53,7 @@ export default function App() { const [pendingConnection, setPendingConnection] = useState(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) + const [exportModalOpen, setExportModalOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope const handleSave = useCallback(async () => { @@ -305,15 +306,10 @@ export default function App() { } }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) - const handleExport = useCallback(async () => { + const handleExport = useCallback(() => { const el = canvasRef.current?.querySelector('.react-flow') if (!el) { toast.error('Canvas not ready'); return } - try { - await exportToPng(el) - toast.success('Exported as PNG') - } catch { - toast.error('Export failed') - } + setExportModalOpen(true) }, []) const handleEdgeConnect = useCallback((connection: Connection) => { @@ -422,6 +418,7 @@ export default function App() { setAddNodeOpen(false)} onSubmit={handleAddNode} @@ -531,6 +528,12 @@ export default function App() { /> setShortcutsOpen(false)} /> + setExportModalOpen(false)} + getElement={() => canvasRef.current?.querySelector('.react-flow') ?? null} + /> + 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__/GroupNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx index 217cc68..f99ffae 100644 --- a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { GroupNode } from '../nodes/GroupNode' import * as canvasStore from '@/stores/canvasStore' import type { Node } from '@xyflow/react' @@ -102,6 +102,19 @@ describe('GroupNode', () => { expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('true') }) + it('allows dragging from the header while keeping rename controls nodrag', () => { + renderGroupNode({ selected: true }) + + expect(screen.getByText('My Group').closest('div')).not.toHaveClass('nodrag') + + const renameButton = screen.getByTitle('Rename group') + expect(renameButton).toHaveClass('nodrag') + + fireEvent.click(renameButton) + + expect(screen.getByDisplayValue('My Group')).toHaveClass('nodrag') + }) + it('shows online/offline status summary from children', () => { const storeNodes = [ { id: 'c1', parentId: 'g1', data: { status: 'online' } }, 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/GroupNode.tsx b/frontend/src/components/canvas/nodes/GroupNode.tsx index 983a730..e8396b6 100644 --- a/frontend/src/components/canvas/nodes/GroupNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupNode.tsx @@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps>) { borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none', pointerEvents: 'auto', }} - className="nodrag" > {editing ? ( setLabelDraft(e.target.value)} onKeyDown={(e) => { @@ -97,11 +97,12 @@ export function GroupNode({ id, data, selected }: NodeProps>) { {editing ? ( <> - - + + ) : ( + ))} +
+ + + + + + + + ) +} diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index b9a4027..e698ce2 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import type { TextPosition } from '@/types' +import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' @@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
- {colorFields.map(({ key, label }) => ( -
-
diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index c39f078..5caa1cd 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -42,8 +42,6 @@ interface NodeModalProps { const CHILD_TYPES: NodeType[] = ['vm', 'lxc'] -// NodeModal is always mounted with a key that changes on open/edit, so useState -// initial value is enough — no need for a reset effect. export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) { const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial }) const [iconSearch, setIconSearch] = useState('') @@ -209,11 +207,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__/ExportModal.test.tsx b/frontend/src/components/modals/__tests__/ExportModal.test.tsx new file mode 100644 index 0000000..5baba23 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ExportModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ExportModal } from '../ExportModal' + +const mockExportToPng = vi.fn() +vi.mock('@/utils/export', () => ({ + exportToPng: (...args: unknown[]) => mockExportToPng(...args), + EXPORT_QUALITY_OPTIONS: [ + { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, + { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, + { value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' }, + ], +})) + +const el = document.createElement('div') +const getElement = () => el +const onClose = vi.fn() + +describe('ExportModal', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExportToPng.mockResolvedValue(undefined) + }) + + it('renders all three quality options', () => { + render() + expect(screen.getByText('Standard')).toBeInTheDocument() + expect(screen.getByText('High')).toBeInTheDocument() + expect(screen.getByText('Ultra')).toBeInTheDocument() + }) + + it('selects High by default', () => { + render() + const highBtn = screen.getByText('High').closest('button')! + expect(highBtn.className).toContain('border-[#00d4ff]') + }) + + it('changes selection when another option is clicked', () => { + render() + fireEvent.click(screen.getByText('Ultra').closest('button')!) + expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]') + expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]') + }) + + it('calls exportToPng with selected quality on Download click', async () => { + render() + fireEvent.click(screen.getByText('Standard').closest('button')!) + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard')) + }) + + it('closes after successful export', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + }) + + it('calls onClose when Cancel is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onClose).toHaveBeenCalled() + }) + + it('does not call exportToPng when getElement returns null', async () => { + render( null} />) + fireEvent.click(screen.getByRole('button', { name: /download/i })) + await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled()) + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx index 7c7233c..4190bca 100644 --- a/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx +++ b/frontend/src/components/modals/__tests__/GroupRectModal.test.tsx @@ -251,4 +251,60 @@ describe('GroupRectModal', () => { const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData expect(submitted.border_style).toBe('solid') }) + + it('shows opacity sliders for all three color fields', () => { + render() + const sliders = screen.getAllByRole('slider') + expect(sliders).toHaveLength(3) + }) + + it('default background_color is 8-digit hex with low alpha', () => { + const onSubmit = vi.fn() + render() + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.background_color).toBe('#00d4ff0d') + expect(submitted.background_color.length).toBe(9) + }) + + it('moving background opacity slider updates background_color alpha', () => { + const onSubmit = vi.fn() + render() + // background slider is the third one (Text, Border, Background) + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[2], { target: { value: '50' } }) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + // alpha 50% → 0x80 = 128 + expect(submitted.background_color).toBe('#00d4ff80') + }) + + it('moving border opacity slider to 0 makes border fully transparent', () => { + const onSubmit = vi.fn() + render() + const sliders = screen.getAllByRole('slider') + fireEvent.change(sliders[1], { target: { value: '0' } }) + fireEvent.click(screen.getByText('Add')) + const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData + expect(submitted.border_color).toBe('#00d4ff00') + }) + + it('pre-fills opacity from 8-digit initial background_color', () => { + render( + + ) + const sliders = screen.getAllByRole('slider') + expect((sliders[2] as HTMLInputElement).value).toBe('50') + }) + + it('shows opacity percentage in label', () => { + render() + // Background default is 5% opacity + expect(screen.getByText(/Background 5%/)).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 4b24085..7a8eee7 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 @@ -130,6 +130,21 @@ describe('NodeModal', () => { expect(data.notes).toBe('rack A') }) + it('resets form values when reopened in Add mode', () => { + const onClose = vi.fn() + const onSubmit = vi.fn() + + const { rerender } = render() + fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } }) + fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } }) + + rerender() + rerender() + + expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('') + expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('') + }) + it('submits check_target', () => { const { onSubmit } = renderModal({ initial: BASE }) fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } }) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 1c9d82b..47b0f1d 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input' import { useCanvasStore } from '@/stores/canvasStore' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { getServiceUrl } from '@/utils/serviceUrl' +import { primaryIp } from '@/utils/maskIp' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import type { Node } from '@xyflow/react' @@ -12,8 +13,8 @@ interface DetailPanelProps { onEdit: (id: string) => void } -type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } -const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } +type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } +const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' } type PropForm = { key: string; value: string; icon: string | null; visible: boolean } const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } @@ -93,10 +94,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { } const handleAddService = () => { - const port = parseInt(newSvc.port, 10) - if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return + const trimmedPort = newSvc.port.trim() + const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10) + if (!newSvc.service_name.trim()) return + if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return snapshotHistory() - const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() } + const path = newSvc.path.trim() + const svc: ServiceInfo = { + ...(port != null ? { port } : {}), + protocol: newSvc.protocol, + service_name: newSvc.service_name.trim(), + ...(path ? { path } : {}), + } updateNode(node.id, { services: [...services, svc] }) setNewSvc(EMPTY_FORM) setAddingForNode(null) @@ -112,18 +121,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { const handleStartEdit = (index: number) => { const svc = services[index] if (!svc) return - setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name }) + setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' }) setEditingFor({ nodeId: node.id, index }) setAddingForNode(null) } const handleSaveEdit = () => { if (editingIndex === null) return - const port = parseInt(editSvc.port, 10) - if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return + const trimmedPort = editSvc.port.trim() + const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10) + if (!editSvc.service_name.trim()) return + if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return snapshotHistory() + const path = editSvc.path.trim() const updated = services.map((svc, i) => - i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc + i === editingIndex + ? { + ...svc, + protocol: editSvc.protocol, + service_name: editSvc.service_name.trim(), + ...(port != null ? { port } : { port: undefined }), + ...(path ? { path } : { path: undefined }), + } + : svc ) updateNode(node.id, { services: updated }) setEditingFor(null) @@ -202,7 +222,14 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} - {data.ip && } + {data.ip && ( +
+ IP Address + + {data.ip} + +
+ )} {data.mac && } {data.os && } {data.check_method && } @@ -272,7 +299,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { editingIndex === i ? ( setEditingFor(null)} confirmLabel="Save" autoFocus /> ) : ( - handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> + handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> ) )}
@@ -472,23 +499,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono? } function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: { - form: { port: string; protocol: 'tcp' | 'udp'; service_name: string } - onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void + form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string } + onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void onConfirm: () => void onCancel: () => void confirmLabel: string autoFocus?: boolean }) { + const setPort = (value: string) => { + const digitsOnly = value.replace(/\D/g, '').slice(0, 5) + onChange({ ...form, port: digitsOnly }) + } + + const clampPort = (value: string) => { + if (!value) return '' + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) return '' + return String(Math.max(1, Math.min(65535, parsed))) + } + return (
onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
- onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> + setPort(e.target.value)} + onBlur={() => onChange({ ...form, port: clampPort(form.port) })} + placeholder="Port" + className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0" + onKeyDown={(e) => e.key === 'Enter' && onConfirm()} + />
+ onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
@@ -611,14 +661,17 @@ const CATEGORY_COLORS: Record = { function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { const url = getServiceUrl(svc, host) const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' + const portLabel = svc.port != null ? String(svc.port) : 'host' + const pathLabel = svc.path?.trim() ? svc.path.trim() : null const inner = (
- {svc.service_name} + {svc.service_name} + {pathLabel && {pathLabel}}
- {svc.port}/{svc.protocol} + {portLabel}/{svc.protocol} {url && } diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index eb77c46..bf382d4 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

)} {devices.map((d) => { - const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port)) + const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port)) const titleService = namedService ?? d.services.find((s) => s.port === 80) ?? d.services.find((s) => s.port === 443) @@ -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__/DetailPanel.test.ts b/frontend/src/components/panels/__tests__/DetailPanel.test.ts index 786a341..92b3ee6 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.ts +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest' import { getServiceUrl } from '@/utils/serviceUrl' import type { ServiceInfo } from '@/types' -const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({ - port, +const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({ + ...(port != null ? { port } : {}), protocol, service_name, + ...(path ? { path } : {}), }) describe('getServiceUrl', () => { @@ -63,4 +64,20 @@ describe('getServiceUrl', () => { it('uses host string directly (works with both IP and hostname)', () => { expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80') }) + + it('uses the node port when the host already includes one', () => { + expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080') + }) + + it('lets the service port override the node port', () => { + expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000') + }) + + it('appends a normalized path to the final URL', () => { + expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login') + }) + + it('supports path-only services inheriting the node port', () => { + expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics') + }) }) diff --git a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx index 3ecad14..e90c400 100644 --- a/frontend/src/components/panels/__tests__/DetailPanel.test.tsx +++ b/frontend/src/components/panels/__tests__/DetailPanel.test.tsx @@ -293,9 +293,35 @@ describe('DetailPanel', () => { fireEvent.click(addHeaders[addHeaders.length - 1]) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } }) fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' }) expect(updateNode).toHaveBeenCalledOnce() - expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' }) + expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' }) + }) + + it('allows adding a service without a port', () => { + const updateNode = vi.fn() + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode({ ip: '192.168.1.10:8080' })], + selectedNodeId: 'n1', + selectedNodeIds: [], + setSelectedNode: vi.fn(), + deleteNode: vi.fn(), + updateNode, + snapshotHistory: vi.fn(), + createGroup: vi.fn(), + ungroup: vi.fn(), + } as unknown as ReturnType) + render() + const addHeaders = screen.getAllByText('Add') + fireEvent.click(addHeaders[addHeaders.length - 1]) + fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } }) + fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement) + + expect(updateNode).toHaveBeenCalledOnce() + expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' }) + expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined() }) it('calls updateNode without the removed service when X is clicked', () => { @@ -332,7 +358,7 @@ describe('DetailPanel', () => { const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } it('shows edit form pre-filled when pencil is clicked', () => { - setupStore({ services: [svc] }) + setupStore({ services: [{ ...svc, path: '/admin' }] }) render() // Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only) const editBtn = screen.getByTitle('Edit service') @@ -341,6 +367,8 @@ describe('DetailPanel', () => { expect(nameInput.value).toBe('nginx') const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement expect(portInput.value).toBe('80') + const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement + expect(pathInput.value).toBe('/admin') }) it('calls updateNode with updated values on Save', () => { @@ -359,11 +387,13 @@ describe('DetailPanel', () => { const nameInput = screen.getByPlaceholderText('Service name') fireEvent.change(nameInput, { target: { value: 'apache' } }) + fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache') expect(updateNode.mock.calls[0][1].services[0].port).toBe(80) + expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin') }) it('cancels edit without updating', () => { @@ -385,4 +415,34 @@ describe('DetailPanel', () => { expect(screen.getByText('nginx')).toBeDefined() }) }) + + describe('IP Address — clickable link', () => { + it('renders a link for a single IP', () => { + setupStore({ ip: '192.168.1.10' }) + render() + const link = screen.getByRole('link', { name: /192\.168\.1\.10/ }) + expect(link).toBeDefined() + expect(link.getAttribute('href')).toBe('http://192.168.1.10') + expect(link.getAttribute('target')).toBe('_blank') + }) + + it('renders no IP link when ip is absent', () => { + setupStore({ ip: undefined }) + render() + expect(screen.queryByText('IP Address')).toBeNull() + }) + + it('uses primary IP as href for comma-separated IPs', () => { + setupStore({ ip: '192.168.1.10, 192.168.1.11' }) + render() + const link = screen.getByRole('link', { name: /192\.168\.1\.10/ }) + expect(link.getAttribute('href')).toBe('http://192.168.1.10') + }) + + it('displays full comma-separated IP string as link text', () => { + setupStore({ ip: '192.168.1.10, 192.168.1.11' }) + render() + expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined() + }) + }) }) 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/types/index.ts b/frontend/src/types/index.ts index 2481349..a9bdaee 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -36,9 +36,10 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown' export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none' export interface ServiceInfo { - port: number + port?: number protocol: 'tcp' | 'udp' service_name: string + path?: string icon?: string category?: string } diff --git a/frontend/src/utils/__tests__/colorUtils.test.ts b/frontend/src/utils/__tests__/colorUtils.test.ts new file mode 100644 index 0000000..bb2b98f --- /dev/null +++ b/frontend/src/utils/__tests__/colorUtils.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { hexToRgba, rgbaToHex8 } from '../colorUtils' + +describe('hexToRgba', () => { + it('splits 8-digit hex into hex6 and alpha', () => { + const { hex6, alpha } = hexToRgba('#00d4ff0d') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(5) + }) + + it('handles fully opaque 8-digit hex (ff)', () => { + const { hex6, alpha } = hexToRgba('#00d4ffff') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(100) + }) + + it('handles fully transparent 8-digit hex (00)', () => { + const { hex6, alpha } = hexToRgba('#00d4ff00') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(0) + }) + + it('defaults alpha to 100 for 6-digit hex', () => { + const { hex6, alpha } = hexToRgba('#00d4ff') + expect(hex6).toBe('#00d4ff') + expect(alpha).toBe(100) + }) + + it('handles 6-digit hex without leading #', () => { + const { hex6, alpha } = hexToRgba('ff6e00') + expect(hex6).toBe('#ff6e00') + expect(alpha).toBe(100) + }) + + it('handles 8-digit hex without leading #', () => { + const { hex6, alpha } = hexToRgba('ff6e0080') + expect(hex6).toBe('#ff6e00') + expect(alpha).toBe(50) + }) + + it('returns fallback for invalid input', () => { + const { hex6, alpha } = hexToRgba('invalid') + expect(hex6).toBe('#000000') + expect(alpha).toBe(100) + }) + + it('is case-insensitive', () => { + const { hex6 } = hexToRgba('#00D4FF0D') + expect(hex6).toBe('#00D4FF') + }) +}) + +describe('rgbaToHex8', () => { + it('combines hex6 and alpha into 8-digit hex', () => { + expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d') + }) + + it('produces ff for alpha 100', () => { + expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff') + }) + + it('produces 00 for alpha 0', () => { + expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00') + }) + + it('produces 80 for alpha 50', () => { + expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080') + }) + + it('clamps alpha below 0 to 0', () => { + expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00') + }) + + it('clamps alpha above 100 to 100', () => { + expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff') + }) + + it('pads single-digit alpha hex with leading zero', () => { + const result = rgbaToHex8('#000000', 1) + const alphaPart = result.slice(7) + expect(alphaPart.length).toBe(2) + }) +}) + +describe('round-trip', () => { + it('hexToRgba → rgbaToHex8 round-trips correctly', () => { + const original = '#00d4ff0d' + const { hex6, alpha } = hexToRgba(original) + expect(rgbaToHex8(hex6, alpha)).toBe(original) + }) + + it('round-trips fully opaque color', () => { + const original = '#a855f7ff' + const { hex6, alpha } = hexToRgba(original) + expect(rgbaToHex8(hex6, alpha)).toBe(original) + }) +}) diff --git a/frontend/src/utils/__tests__/export.test.ts b/frontend/src/utils/__tests__/export.test.ts new file mode 100644 index 0000000..9d42221 --- /dev/null +++ b/frontend/src/utils/__tests__/export.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export' + +const mockToPng = vi.fn() +vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) })) + +describe('exportToPng', () => { + let el: HTMLElement + let clickSpy: ReturnType + let appendSpy: ReturnType + let createSpy: ReturnType + + beforeEach(() => { + el = document.createElement('div') + clickSpy = vi.fn() + createSpy = vi.spyOn(document, 'createElement').mockReturnValue( + Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement + ) + appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n) + mockToPng.mockResolvedValue('data:image/png;base64,abc') + }) + + afterEach(() => { + createSpy.mockRestore() + appendSpy.mockRestore() + }) + + it('calls toPng with pixelRatio 1 for standard quality', async () => { + await exportToPng(el, 'standard') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 })) + }) + + it('calls toPng with pixelRatio 2 for high quality', async () => { + await exportToPng(el, 'high') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 })) + }) + + it('calls toPng with pixelRatio 4 for ultra quality', async () => { + await exportToPng(el, 'ultra') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 })) + }) + + it('defaults to high quality when no quality arg given', async () => { + await exportToPng(el) + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 })) + }) + + it('triggers a download with the correct filename', async () => { + await exportToPng(el, 'high') + expect(clickSpy).toHaveBeenCalled() + }) + + it('passes dark background color', async () => { + await exportToPng(el, 'standard') + expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' })) + }) +}) + +describe('EXPORT_QUALITY_OPTIONS', () => { + it('has exactly three options', () => { + expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3) + }) + + it('options are standard, high, ultra in order', () => { + expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra']) + }) + + it('pixel ratios are 1, 2, 4', () => { + expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4]) + }) +}) 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/__tests__/propertyIcons.test.ts b/frontend/src/utils/__tests__/propertyIcons.test.ts index e2e2ad9..ccdcb98 100644 --- a/frontend/src/utils/__tests__/propertyIcons.test.ts +++ b/frontend/src/utils/__tests__/propertyIcons.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect } from 'vitest' -import { Cpu, HardDrive, MemoryStick } from 'lucide-react' +import { CircuitBoard, Cpu, EthernetPort, Gpu, HardDrive, HdmiPort, MemoryStick, Usb } from 'lucide-react' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons' describe('PROPERTY_ICONS', () => { it('contains the hardware migration icons', () => { + expect(PROPERTY_ICONS['CircuitBoard']).toBe(CircuitBoard) expect(PROPERTY_ICONS['Cpu']).toBe(Cpu) + expect(PROPERTY_ICONS['EthernetPort']).toBe(EthernetPort) + expect(PROPERTY_ICONS['Gpu']).toBe(Gpu) expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive) + expect(PROPERTY_ICONS['HdmiPort']).toBe(HdmiPort) expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick) + expect(PROPERTY_ICONS['Usb']).toBe(Usb) }) it('has at least 10 icons', () => { 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/colorUtils.ts b/frontend/src/utils/colorUtils.ts new file mode 100644 index 0000000..0e62436 --- /dev/null +++ b/frontend/src/utils/colorUtils.ts @@ -0,0 +1,29 @@ +/** + * Split a 6- or 8-digit hex color into its RGB hex and alpha (0–100). + * 6-digit input returns alpha 100. + * Invalid input returns { hex6: '#000000', alpha: 100 }. + */ +export function hexToRgba(hex: string): { hex6: string; alpha: number } { + const clean = hex.replace('#', '') + if (clean.length === 8) { + const alphaByte = parseInt(clean.slice(6, 8), 16) + return { + hex6: `#${clean.slice(0, 6)}`, + alpha: Math.round((alphaByte / 255) * 100), + } + } + if (clean.length === 6) { + return { hex6: `#${clean}`, alpha: 100 } + } + return { hex6: '#000000', alpha: 100 } +} + +/** + * Combine a 6-digit hex color and an alpha (0–100) into an 8-digit hex. + */ +export function rgbaToHex8(hex6: string, alpha: number): string { + const clamped = Math.max(0, Math.min(100, alpha)) + const alphaByte = Math.round((clamped / 100) * 255) + const alphaHex = alphaByte.toString(16).padStart(2, '0') + return `${hex6}${alphaHex}` +} diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts index 9705e69..83e53ab 100644 --- a/frontend/src/utils/export.ts +++ b/frontend/src/utils/export.ts @@ -1,14 +1,19 @@ import { toPng } from 'html-to-image' -/** - * Export the React Flow canvas as a PNG and trigger a browser download. - * Pass the `.react-flow` wrapper element. - */ -export async function exportToPng(element: HTMLElement): Promise { +export type ExportQuality = 'standard' | 'high' | 'ultra' + +export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [ + { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' }, + { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' }, + { value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' }, +] + +export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise { + const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1] const dataUrl = await toPng(element, { backgroundColor: '#0d1117', + pixelRatio: option.pixelRatio, style: { - // Exclude controls from the export '--xy-controls-display': 'none', } as Partial, }) diff --git a/frontend/src/utils/exportMarkdown.ts b/frontend/src/utils/exportMarkdown.ts index 19808c5..4dfef69 100644 --- a/frontend/src/utils/exportMarkdown.ts +++ b/frontend/src/utils/exportMarkdown.ts @@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node[]): string { .map((n) => { const d = n.data const services = d.services?.length - ? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ') + ? d.services.map((s) => { + const port = s.port != null ? `:${s.port}` : '' + const path = s.path?.trim() ? s.path.trim() : '' + return `${s.service_name}${port}${path}` + }).join(', ') : EMPTY return [ cell(d.label), diff --git a/frontend/src/utils/maskIp.ts b/frontend/src/utils/maskIp.ts index 7c0e8c9..8dfa9f8 100644 --- a/frontend/src/utils/maskIp.ts +++ b/frontend/src/utils/maskIp.ts @@ -1,10 +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. + */ +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] ?? '' } diff --git a/frontend/src/utils/propertyIcons.ts b/frontend/src/utils/propertyIcons.ts index af24a00..b9826fa 100644 --- a/frontend/src/utils/propertyIcons.ts +++ b/frontend/src/utils/propertyIcons.ts @@ -1,11 +1,15 @@ import { Battery, Box, + CircuitBoard, Clock, Cpu, Database, + EthernetPort, Globe, + Gpu, HardDrive, + HdmiPort, Hash, Key, Layers, @@ -17,6 +21,7 @@ import { Shield, Tag, Thermometer, + Usb, Wifi, Zap, } from 'lucide-react' @@ -25,11 +30,15 @@ import type { LucideIcon } from 'lucide-react' export const PROPERTY_ICONS: Record = { Battery, Box, + CircuitBoard, Clock, Cpu, Database, + EthernetPort, Globe, + Gpu, HardDrive, + HdmiPort, Hash, Key, Layers, @@ -41,6 +50,7 @@ export const PROPERTY_ICONS: Record = { Shield, Tag, Thermometer, + Usb, Wifi, Zap, } diff --git a/frontend/src/utils/serviceUrl.ts b/frontend/src/utils/serviceUrl.ts index b90d061..b2719e6 100644 --- a/frontend/src/utils/serviceUrl.ts +++ b/frontend/src/utils/serviceUrl.ts @@ -20,15 +20,82 @@ const NON_HTTP_PORTS = new Set([ 27017, 27018, // MongoDB ]) +function splitFirstHost(host: string): string { + return host.split(',')[0]?.trim() ?? '' +} + +function parsePort(port: string): number | undefined { + if (!/^\d+$/.test(port)) return undefined + const parsed = Number.parseInt(port, 10) + return parsed >= 1 && parsed <= 65535 ? parsed : undefined +} + +function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null { + const firstHost = splitFirstHost(host) + if (!firstHost) return null + + if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) { + const url = new URL(firstHost) + return { + protocol: url.protocol === 'https:' ? 'https' : 'http', + hostname: url.hostname, + port: parsePort(url.port), + } + } + + if (firstHost.startsWith('[')) { + const bracketIndex = firstHost.indexOf(']') + if (bracketIndex === -1) return { hostname: firstHost } + const hostname = firstHost.slice(1, bracketIndex) + const remainder = firstHost.slice(bracketIndex + 1) + return { + hostname, + port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined, + } + } + + const colonCount = (firstHost.match(/:/g) ?? []).length + if (colonCount === 1) { + const [hostname, rawPort] = firstHost.split(':') + const parsedPort = parsePort(rawPort) + if (hostname && parsedPort != null) { + return { hostname, port: parsedPort } + } + } + + return { hostname: firstHost } +} + +function normalizePath(path?: string): string { + const trimmed = path?.trim() + if (!trimmed) return '' + if (trimmed === '/') return '/' + return trimmed.startsWith('/') ? trimmed : `/${trimmed}` +} + +function formatHostname(hostname: string): string { + return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname +} + export function getServiceUrl(svc: ServiceInfo, host?: string): string | null { if (!host) return null - if (svc.port === 22) return null // SSH — no browser if (svc.protocol === 'udp') return null // UDP — not HTTP - if (NON_HTTP_PORTS.has(svc.port)) return null + + const parts = parseHostParts(host) + if (!parts?.hostname) return null + + const effectivePort = svc.port ?? parts.port + if (effectivePort === 22) return null // SSH — no browser + if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null const name = svc.service_name.toLowerCase() - const isHttps = + const protocol = parts.protocol ?? ( name.includes('https') || name.includes('ssl') || name.includes('tls') || - svc.port === 443 || svc.port === 8443 - return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}` + effectivePort === 443 || effectivePort === 8443 + ? 'https' + : 'http' + ) + const base = `${protocol}://${formatHostname(parts.hostname)}` + const port = effectivePort != null ? `:${effectivePort}` : '' + return `${base}${port}${normalizePath(svc.path)}` } 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: {