@@ -9,6 +9,7 @@ COPY backend/requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
# Create data directory (volume mount point)
|
# Create data directory (volume mount point)
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
COPY VERSION ../VERSION
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: serve
|
# Stage 2: serve
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
|||||||
from app.services.scanner import request_cancel, run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
|
||||||
|
|
||||||
|
class BulkActionRequest(BaseModel):
|
||||||
|
device_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
ranges: list[str]
|
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())
|
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)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
async def approve_device(
|
async def approve_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
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.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
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
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -23,7 +27,22 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
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:
|
async def init_db() -> None:
|
||||||
|
_backup_db()
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Add columns introduced after initial schema (idempotent)
|
# Add columns introduced after initial schema (idempotent)
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
if check_method == "none":
|
if check_method == "none":
|
||||||
return {"status": "online", "response_time_ms": 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:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
scan_config.json
|
scan_config.json
|
||||||
|
homelab.db.*
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -444,3 +444,101 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
|||||||
# Services and hostname should be updated
|
# Services and hostname should be updated
|
||||||
assert device.hostname == "myhost.lan"
|
assert device.hostname == "myhost.lan"
|
||||||
assert any(s["port"] == 8096 for s in device.services)
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const scanApi = {
|
|||||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
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`),
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -143,6 +144,7 @@ const XYFLOW_MOCK = {
|
|||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ vi.mock('@/utils/nodeIcons', () => ({
|
|||||||
|
|
||||||
vi.mock('@/utils/maskIp', () => ({
|
vi.mock('@/utils/maskIp', () => ({
|
||||||
maskIp: (ip: string) => ip,
|
maskIp: (ip: string) => ip,
|
||||||
|
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/propertyIcons', () => ({
|
vi.mock('@/utils/propertyIcons', () => ({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => <div data-testid={`handle-${id}`} data-type={type} />,
|
||||||
|
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<NodeData> = {}): Node<NodeData> {
|
||||||
|
return {
|
||||||
|
id: 'zone1',
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderZone(overrides: Partial<NodeData> = {}) {
|
||||||
|
const node = makeNode(overrides)
|
||||||
|
return render(
|
||||||
|
<GroupRectNode
|
||||||
|
id={node.id}
|
||||||
|
data={node.data}
|
||||||
|
selected={false}
|
||||||
|
type="groupRect"
|
||||||
|
dragging={false}
|
||||||
|
zIndex={0}
|
||||||
|
isConnectable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,7 +8,7 @@ import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
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'
|
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
@@ -43,7 +43,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
@@ -77,7 +77,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
@@ -98,15 +98,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</div>
|
</div>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<div
|
<div
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[10px] truncate"
|
className="font-mono text-[10px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
title={data.ip}
|
title={ip}
|
||||||
>
|
>
|
||||||
{hideIp ? maskIp(data.ip) : data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,14 +115,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
{visibleProperties && visibleProperties.length > 0 && (
|
{visibleProperties && visibleProperties.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
{visibleProperties.map((prop) => {
|
{visibleProperties.map((prop) => {
|
||||||
const Icon = resolvePropertyIcon(prop.icon)
|
const Icon = resolvePropertyIcon(prop.icon)
|
||||||
return (
|
return (
|
||||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
{Icon && <Icon size={9} className="shrink-0" />}
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
<span className="truncate" title={prop.value}>· {prop.value}</span>
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import type { NodeData, TextPosition } from '@/types'
|
import type { NodeData, TextPosition } from '@/types'
|
||||||
|
|
||||||
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
|||||||
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
|
'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<Node<NodeData>>) {
|
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||||
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
const borderColor = rc.border ?? '#00d4ff'
|
||||||
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
whiteSpace: 'pre-wrap',
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
@@ -75,6 +94,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
}}
|
}}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{HANDLE_SIDES.map(({ id: hid, position }) => (
|
||||||
|
<span key={hid}>
|
||||||
|
<Handle type="source" id={hid} position={position} style={handleStyle} />
|
||||||
|
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -92,6 +119,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setEditingGroupRectId(id)
|
setEditingGroupRectId(id)
|
||||||
|
|||||||
@@ -209,11 +209,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
{/* IP */}
|
{/* IP */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.ip ?? ''}
|
value={form.ip ?? ''}
|
||||||
onChange={(e) => set('ip', e.target.value)}
|
onChange={(e) => 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"
|
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ describe('NodeModal', () => {
|
|||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('My Server')
|
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('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 ────────────────────────────────────────────────────────────
|
// ── Cancel ────────────────────────────────────────────────────────────
|
||||||
@@ -121,7 +121,7 @@ describe('NodeModal', () => {
|
|||||||
it('submits updated hostname, IP and notes', () => {
|
it('submits updated hostname, IP and notes', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
|
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.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
|||||||
@@ -165,9 +165,26 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
const highlightRef = useRef<HTMLButtonElement>(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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -184,12 +201,58 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
try {
|
try {
|
||||||
await scanApi.clearPending()
|
await scanApi.clearPending()
|
||||||
setDevices([])
|
setDevices([])
|
||||||
|
setCheckedIds(new Set())
|
||||||
toast.success('Pending devices cleared')
|
toast.success('Pending devices cleared')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to clear pending devices')
|
toast.error('Failed to clear pending devices')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBulkApprove = async () => {
|
||||||
|
const ids = [...checkedIds]
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkApprove(ids)
|
||||||
|
const deviceToNode: Record<string, string> = {}
|
||||||
|
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(() => { load() }, [load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,7 +314,19 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
<>
|
<>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allChecked}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title="Select all"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
||||||
<RefreshCw size={12} />
|
<RefreshCw size={12} />
|
||||||
@@ -263,6 +338,22 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{someChecked && (
|
||||||
|
<div className="flex items-center gap-1 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Approve ({checkedIds.size})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkHide}
|
||||||
|
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Hide ({checkedIds.size})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||||
{!loading && devices.length === 0 && (
|
{!loading && devices.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||||
@@ -288,10 +379,16 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
|||||||
key={d.id}
|
key={d.id}
|
||||||
ref={isHighlighted ? highlightRef : null}
|
ref={isHighlighted ? highlightRef : null}
|
||||||
onClick={() => setSelected(d)}
|
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]'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedIds.has(d.id)}
|
||||||
|
onClick={(e) => toggleCheck(d.id, e)}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
<span className="text-foreground truncate font-medium">{title}</span>
|
<span className="text-foreground truncate font-medium">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
{showIpBelow && (
|
{showIpBelow && (
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { NodeData } from '@/types'
|
|||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
const mockBulkApprove = vi.fn()
|
||||||
|
const mockBulkHide = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
trigger: vi.fn().mockResolvedValue({}),
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
@@ -16,6 +19,12 @@ vi.mock('@/api/client', () => ({
|
|||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
stop: vi.fn().mockResolvedValue({}),
|
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: {
|
settingsApi: {
|
||||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
@@ -259,3 +268,100 @@ describe('Sidebar', () => {
|
|||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
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(<Sidebar {...defaultProps} forceView="pending" />)
|
||||||
|
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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { maskIp } from '../maskIp'
|
import { maskIp, splitIps, primaryIp } from '../maskIp'
|
||||||
|
|
||||||
describe('maskIp', () => {
|
describe('maskIp', () => {
|
||||||
|
// IPv4
|
||||||
it('masks last two octets of a standard IPv4', () => {
|
it('masks last two octets of a standard IPv4', () => {
|
||||||
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
|
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')
|
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('hostname')).toBe('hostname')
|
||||||
expect(maskIp('fe80::1')).toBe('fe80::1')
|
|
||||||
expect(maskIp('')).toBe('')
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
disk_gb: n.data.disk_gb ?? null,
|
disk_gb: n.data.disk_gb ?? null,
|
||||||
show_hardware: n.data.show_hardware ?? false,
|
show_hardware: n.data.show_hardware ?? false,
|
||||||
properties: n.data.properties ?? [],
|
properties: n.data.properties ?? [],
|
||||||
width: n.width ?? null,
|
width: n.measured?.width ?? n.width ?? null,
|
||||||
height: n.height ?? null,
|
height: n.measured?.height ?? n.height ?? null,
|
||||||
bottom_handles: n.data.bottom_handles ?? 1,
|
bottom_handles: n.data.bottom_handles ?? 1,
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
@@ -156,11 +156,8 @@ export function deserializeApiNode(
|
|||||||
position: { x: n.pos_x, y: n.pos_y },
|
position: { x: n.pos_x, y: n.pos_y },
|
||||||
data: n as unknown as NodeData,
|
data: n as unknown as NodeData,
|
||||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||||
...(n.type === 'proxmox' && n.container_mode !== false
|
...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}),
|
||||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}),
|
||||||
: {}),
|
|
||||||
...(n.width && n.type !== 'proxmox' ? { width: n.width } : {}),
|
|
||||||
...(n.height && n.type !== 'proxmox' ? { height: n.height } : {}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,45 @@
|
|||||||
/**
|
/**
|
||||||
* Mask the last two octets of an IPv4 address.
|
* Mask a single IP address:
|
||||||
* e.g. "192.168.1.115" → "192.168.XX.XX"
|
* - IPv4 "192.168.1.115" → "192.168.XX.XX"
|
||||||
* Non-IPv4 strings are returned unchanged.
|
* - IPv6 "2001:db8::1" → "2001:XX::XX"
|
||||||
|
* - Other strings returned unchanged.
|
||||||
*/
|
*/
|
||||||
export function maskIp(ip: string): string {
|
function maskSingle(ip: string): string {
|
||||||
const parts = ip.split('.')
|
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`
|
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 ''
|
* Mask all IPs in a comma-separated string.
|
||||||
return ip.split(',')[0].trim()
|
* 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] ?? ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import pkg from './package.json'
|
|
||||||
|
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
define: {
|
define: {
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
},
|
},
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user