Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cf6aec987 | |||
| ea6e3dc02e | |||
| 2a79161106 | |||
| 97a7d7da3d | |||
| 7e3abf8889 | |||
| 548da952c5 | |||
| fda5e6c16c | |||
| b64716c15c | |||
| c842ff1ff3 |
+2
-3
@@ -2,14 +2,13 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 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/*
|
||||
# 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/*
|
||||
|
||||
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
|
||||
|
||||
@@ -12,7 +12,6 @@ COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ .
|
||||
COPY VERSION ../VERSION
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve
|
||||
|
||||
@@ -17,10 +17,6 @@ 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]
|
||||
|
||||
@@ -103,61 +99,6 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
async def bulk_approve_devices(
|
||||
payload: BulkActionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
node_ids: list[str] = []
|
||||
for device in devices:
|
||||
device.status = "approved"
|
||||
node = Node(
|
||||
label=device.hostname or device.ip,
|
||||
type=device.suggested_type or "generic",
|
||||
ip=device.ip,
|
||||
hostname=device.hostname,
|
||||
status="unknown",
|
||||
services=device.services or [],
|
||||
)
|
||||
db.add(node)
|
||||
node_ids.append(node.id)
|
||||
await db.commit()
|
||||
approved_device_ids = [d.id for d in devices]
|
||||
return {
|
||||
"approved": len(node_ids),
|
||||
"node_ids": node_ids,
|
||||
"device_ids": approved_device_ids,
|
||||
"skipped": len(payload.device_ids) - len(node_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pending/bulk-hide", response_model=dict)
|
||||
async def bulk_hide_devices(
|
||||
payload: BulkActionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
device.status = "hidden"
|
||||
await db.commit()
|
||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||
async def approve_device(
|
||||
device_id: str,
|
||||
|
||||
@@ -7,17 +7,6 @@ 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")
|
||||
|
||||
+11
-20
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
import shutil
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -8,9 +6,7 @@ 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 APP_VERSION, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.core.config import settings
|
||||
|
||||
# Ensure the data directory exists before SQLite tries to open the file
|
||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -27,22 +23,7 @@ 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)
|
||||
@@ -113,6 +94,16 @@ async def init_db() -> None:
|
||||
with suppress(OperationalError):
|
||||
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
|
||||
await conn.exec_driver_sql(sql)
|
||||
# Ensure existing proxmox nodes have container_mode=1 (they were always containers before the flag existed)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET container_mode = 1 WHERE type = 'proxmox' AND container_mode = 0"
|
||||
)
|
||||
# Rename legacy 'docker' type → 'docker_container'
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET type = 'docker_container' WHERE type = 'docker'"
|
||||
)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
@@ -19,9 +19,7 @@ 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}
|
||||
|
||||
# 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
|
||||
host = target or ip
|
||||
if not host:
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
scan_config.json
|
||||
homelab.db.*
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Tests for docker type rename and proxmox container_mode migrations.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
async def _setup_table(conn):
|
||||
await conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'generic',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
container_mode BOOLEAN NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def _run_migrations(conn):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET container_mode = 1 WHERE type = 'proxmox' AND container_mode = 0"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET type = 'docker_container' WHERE type = 'docker'"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxmox_container_mode_set_to_true():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('p1', 'proxmox', 'PVE', 0)"
|
||||
)
|
||||
await _run_migrations(conn)
|
||||
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 'p1'")).fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxmox_already_true_unchanged():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('p2', 'proxmox', 'PVE', 1)"
|
||||
)
|
||||
await _run_migrations(conn)
|
||||
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 'p2'")).fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_proxmox_container_mode_untouched():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, container_mode) VALUES ('s1', 'server', 'Srv', 0)"
|
||||
)
|
||||
await _run_migrations(conn)
|
||||
row = (await conn.exec_driver_sql("SELECT container_mode FROM nodes WHERE id = 's1'")).fetchone()
|
||||
assert row[0] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_type_renamed_to_docker_container():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label) VALUES ('d1', 'docker', 'My Docker')"
|
||||
)
|
||||
await _run_migrations(conn)
|
||||
row = (await conn.exec_driver_sql("SELECT type FROM nodes WHERE id = 'd1'")).fetchone()
|
||||
assert row[0] == 'docker_container'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docker_host_type_untouched():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label) VALUES ('d2', 'docker_host', 'Docker Host')"
|
||||
)
|
||||
await _run_migrations(conn)
|
||||
row = (await conn.exec_driver_sql("SELECT type FROM nodes WHERE id = 'd2'")).fetchone()
|
||||
assert row[0] == 'docker_host'
|
||||
@@ -444,101 +444,3 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
|
||||
|
||||
# --- Bulk approve ---
|
||||
|
||||
@pytest.fixture
|
||||
async def two_pending_devices(db_session):
|
||||
devices = []
|
||||
for i in range(2):
|
||||
d = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=f"192.168.1.{10 + i}",
|
||||
mac=None,
|
||||
hostname=f"host-{i}",
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(d)
|
||||
devices.append(d)
|
||||
await db_session.commit()
|
||||
for d in devices:
|
||||
await db_session.refresh(d)
|
||||
return devices
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 2
|
||||
assert len(data["node_ids"]) == 2
|
||||
assert len(data["device_ids"]) == 2
|
||||
assert data["skipped"] == 0
|
||||
# Pending list should now be empty
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
# Approve first device individually first
|
||||
await client.post(
|
||||
f"/api/v1/scan/pending/{ids[0]}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
# Bulk approve both — first one is already approved (not pending), should be skipped
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# --- Bulk hide ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 2
|
||||
assert data["skipped"] == 0
|
||||
# Should appear in hidden list
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
# Hide first device individually first
|
||||
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
||||
# Bulk hide both — first is already hidden (not pending anymore)
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.10.0",
|
||||
"version": "1.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+11
-6
@@ -33,6 +33,7 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'docker_host'])
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||
@@ -102,8 +103,8 @@ export default function App() {
|
||||
// Build a map of proxmox container mode to know if children should be nested
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
@@ -241,8 +242,8 @@ export default function App() {
|
||||
snapshotHistory()
|
||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||
updateNode(editNodeId, data)
|
||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
|
||||
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (typeof data.container_mode === 'boolean') {
|
||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||
}
|
||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||
@@ -426,7 +427,9 @@ export default function App() {
|
||||
onClose={() => setAddNodeOpen(false)}
|
||||
onSubmit={handleAddNode}
|
||||
title="Add Node"
|
||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
parentContainerNodes={nodes
|
||||
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
||||
.map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||
@@ -437,7 +440,9 @@ export default function App() {
|
||||
onSubmit={handleUpdateNode}
|
||||
initial={editNode?.data}
|
||||
title="Edit Node"
|
||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
parentContainerNodes={nodes
|
||||
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
||||
.map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
/>
|
||||
|
||||
<EdgeModal
|
||||
|
||||
@@ -60,8 +60,6 @@ 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),
|
||||
|
||||
@@ -11,7 +11,6 @@ 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', () => ({}))
|
||||
@@ -144,7 +143,6 @@ const XYFLOW_MOCK = {
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ 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', () => ({
|
||||
|
||||
@@ -20,7 +20,6 @@ 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() }),
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { GroupNode } from '../nodes/GroupNode'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
@@ -102,19 +102,6 @@ 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' } },
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
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 { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { maskIp } from '@/utils/maskIp'
|
||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||
|
||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
@@ -43,7 +43,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
background: colors.background,
|
||||
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 }} />
|
||||
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
@@ -98,16 +98,15 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
{data.ip && splitIps(data.ip).map((ip) => (
|
||||
{data.ip && (
|
||||
<div
|
||||
key={ip}
|
||||
className="font-mono text-[10px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
title={ip}
|
||||
title={data.ip}
|
||||
>
|
||||
{hideIp ? maskIp(ip) : ip}
|
||||
{hideIp ? maskIp(data.ip) : data.ip}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,14 +114,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
{visibleProperties && visibleProperties.length > 0 && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{visibleProperties.map((prop) => {
|
||||
const Icon = resolvePropertyIcon(prop.icon)
|
||||
return (
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
<span className="truncate" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -66,13 +66,13 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
className="nodrag"
|
||||
>
|
||||
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
|
||||
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
className="nodrag"
|
||||
value={labelDraft}
|
||||
onChange={(e) => setLabelDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -97,12 +97,11 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<button className="nodrag" onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||
<button className="nodrag" onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||
<button onClick={handleRename} style={{ color: '#39d353', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><Check size={11} /></button>
|
||||
<button onClick={() => { setLabelDraft(data.label); setEditing(false) }} style={{ color: '#f85149', background: 'none', border: 'none', cursor: 'pointer', padding: 1 }}><X size={11} /></button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="nodrag"
|
||||
onClick={() => { setLabelDraft(data.label); setEditing(true) }}
|
||||
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 1, opacity: selected ? 1 : 0 }}
|
||||
title="Rename group"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
@@ -27,16 +26,8 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
|
||||
'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>>) {
|
||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
@@ -69,16 +60,6 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
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 (
|
||||
<>
|
||||
<NodeResizer
|
||||
@@ -94,14 +75,6 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
}}
|
||||
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
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -119,8 +92,6 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingGroupRectId(id)
|
||||
|
||||
@@ -23,4 +23,5 @@ export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Container} />
|
||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, DockerContainerNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
@@ -18,7 +18,8 @@ export const nodeTypes = {
|
||||
printer: PrinterNode,
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
docker: DockerNode,
|
||||
docker_container: DockerContainerNode,
|
||||
docker_host: DockerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
group: GroupNode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { Fragment, createElement, useState } from 'react'
|
||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,12 +11,13 @@ import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||
|
||||
const DEFAULT_DATA: Partial<NodeData> = {
|
||||
type: 'server',
|
||||
@@ -26,7 +27,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
||||
status: 'unknown',
|
||||
check_method: 'ping',
|
||||
services: [],
|
||||
container_mode: true,
|
||||
container_mode: false,
|
||||
custom_colors: undefined,
|
||||
custom_icon: undefined,
|
||||
}
|
||||
@@ -37,14 +38,12 @@ interface NodeModalProps {
|
||||
onSubmit: (data: Partial<NodeData>) => void
|
||||
initial?: Partial<NodeData>
|
||||
title?: string
|
||||
proxmoxNodes?: { id: string; label: string }[]
|
||||
parentContainerNodes?: { id: string; label: string }[]
|
||||
}
|
||||
|
||||
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) {
|
||||
// initial value is enough - no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
@@ -60,7 +59,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
return
|
||||
}
|
||||
setLabelError(false)
|
||||
onSubmit(form)
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
onSubmit({
|
||||
...form,
|
||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -78,13 +82,13 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
||||
<SelectValue />
|
||||
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||
<>
|
||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||
<SelectGroup key={group.label}>
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||
{group.label}
|
||||
</SelectLabel>
|
||||
@@ -94,7 +98,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -134,7 +138,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline icon picker — full width, shown below the type+icon row */}
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<Input
|
||||
@@ -209,11 +213,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
|
||||
{/* IP */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
||||
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
||||
<Input
|
||||
value={form.ip ?? ''}
|
||||
onChange={(e) => set('ip', e.target.value)}
|
||||
placeholder="192.168.1.x, 2001:db8::1"
|
||||
placeholder="192.168.1.x"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -244,10 +248,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parent Proxmox (VM / LXC only) */}
|
||||
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
||||
{/* Parent container */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && parentContainerNodes.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
|
||||
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||
<Select
|
||||
value={form.parent_id ?? 'none'}
|
||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||
@@ -257,7 +261,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
||||
{proxmoxNodes.map((n) => (
|
||||
{parentContainerNodes.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -265,12 +269,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container mode (proxmox only) */}
|
||||
{form.type === 'proxmox' && (
|
||||
{/* Container mode */}
|
||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -344,10 +348,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="1" className="text-sm">1 — center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 — left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 — left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 — evenly spaced</SelectItem>
|
||||
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Search } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import { NODE_TYPE_LABELS } from '@/types'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
@@ -89,7 +90,7 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelectNode(node.id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{NODE_TYPE_LABELS[node.data.type] ?? node.data.type}</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||
{node.data.ip && (
|
||||
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
|
||||
|
||||
@@ -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, 2001:db8::1') as HTMLInputElement).value).toBe('192.168.1.10')
|
||||
expect((screen.getByPlaceholderText('192.168.1.x') 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, 2001:db8::1'), { target: { value: '10.0.0.1' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('192.168.1.x'), { 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<NodeData>
|
||||
@@ -219,15 +219,20 @@ describe('NodeModal', () => {
|
||||
expect(screen.queryByTitle('Router')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Container mode (proxmox only) ─────────────────────────────────────
|
||||
// ── Container mode ─────────────────────────────────────────────────────
|
||||
|
||||
it('shows Container Mode toggle for proxmox type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
||||
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Container Mode for non-proxmox types', () => {
|
||||
renderModal({ initial: BASE })
|
||||
it('hides Container Mode for server type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'server' } })
|
||||
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Container Mode for groupRect type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -238,33 +243,25 @@ describe('NodeModal', () => {
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||
})
|
||||
|
||||
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||
// ── Parent container ──────────────────────────────────────────────────
|
||||
|
||||
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
|
||||
it('shows Parent Container when options are provided', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'vm' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
initial: { ...BASE, type: 'server' },
|
||||
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('PVE-01')).toBeDefined()
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
expect(screen.getByText('Container 01')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'lxc' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
it('hides Parent Container for groupRect type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'groupRect' }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for server type', () => {
|
||||
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'vm' } })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
it('hides Parent Container when no container options are available', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'server' } })
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
|
||||
@@ -203,14 +202,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.ip && (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
||||
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||
|
||||
@@ -165,26 +165,9 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -201,58 +184,12 @@ 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<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(() => {
|
||||
@@ -314,19 +251,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
<>
|
||||
<div className="p-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>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
||||
<RefreshCw size={12} />
|
||||
@@ -338,22 +263,6 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
|
||||
)}
|
||||
</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 && devices.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||
@@ -379,16 +288,10 @@ 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]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : '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]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||
<span className="text-foreground truncate font-medium">{title}</span>
|
||||
</div>
|
||||
{showIpBelow && (
|
||||
|
||||
@@ -385,34 +385,4 @@ 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(<DetailPanel onEdit={vi.fn()} />)
|
||||
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(<DetailPanel onEdit={vi.fn()} />)
|
||||
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(<DetailPanel onEdit={vi.fn()} />)
|
||||
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(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,9 +9,6 @@ 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({}),
|
||||
@@ -19,12 +16,6 @@ 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 } }),
|
||||
@@ -268,100 +259,3 @@ 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(<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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,21 @@ describe('canvasStore', () => {
|
||||
expect(hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('addNode nests under parent only when parent is in container mode', () => {
|
||||
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
|
||||
const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } }
|
||||
useCanvasStore.getState().addNode(parent)
|
||||
useCanvasStore.getState().addNode(child)
|
||||
const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
|
||||
expect(childNode?.parentId).toBeUndefined()
|
||||
|
||||
useCanvasStore.getState().updateNode('p1', { container_mode: true })
|
||||
useCanvasStore.getState().setProxmoxContainerMode('p1', true)
|
||||
const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
|
||||
expect(nested?.parentId).toBe('p1')
|
||||
expect(nested?.extent).toBe('parent')
|
||||
})
|
||||
|
||||
it('updateNode updates data fields', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
|
||||
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
|
||||
@@ -84,7 +99,7 @@ describe('canvasStore', () => {
|
||||
|
||||
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
|
||||
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
|
||||
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const }
|
||||
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const }
|
||||
useCanvasStore.getState().addNode(proxmox)
|
||||
useCanvasStore.getState().addNode(lxc)
|
||||
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
|
||||
@@ -214,7 +229,7 @@ describe('canvasStore', () => {
|
||||
})
|
||||
|
||||
it('deleteNode also removes children with matching parentId', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('parent'))
|
||||
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
|
||||
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
||||
useCanvasStore.getState().deleteNode('parent')
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
@@ -223,7 +238,7 @@ describe('canvasStore', () => {
|
||||
})
|
||||
|
||||
it('addNode with parent_id sets parentId and extent', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('parent'))
|
||||
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
|
||||
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
||||
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
|
||||
expect(child?.parentId).toBe('parent')
|
||||
|
||||
@@ -172,8 +172,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
addNode: (node) =>
|
||||
set((state) => {
|
||||
const enriched = node.data.parent_id
|
||||
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
|
||||
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
|
||||
const shouldNestInParent = !!(parent?.data.container_mode)
|
||||
const enriched = node.data.parent_id && shouldNestInParent
|
||||
? {
|
||||
...node,
|
||||
parentId: node.data.parent_id,
|
||||
extent: 'parent' as const,
|
||||
position: {
|
||||
x: Math.max(10, node.position.x - parent.position.x),
|
||||
y: Math.max(10, node.position.y - parent.position.y),
|
||||
},
|
||||
}
|
||||
: node
|
||||
// Parents must come before children in the array (React Flow requirement)
|
||||
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||
@@ -283,14 +293,38 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
setProxmoxContainerMode: (proxmoxId, enabled) =>
|
||||
set((state) => {
|
||||
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
|
||||
let nodes = state.nodes.map((n) => {
|
||||
if (n.id === proxmoxId) {
|
||||
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
|
||||
if (n.data.type !== 'proxmox') return withMode
|
||||
return enabled
|
||||
? { ...withMode, width: 300, height: 200 }
|
||||
? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: { ...withMode, width: undefined, height: undefined }
|
||||
}
|
||||
if (n.data.parent_id === proxmoxId) {
|
||||
if (enabled && parentNode) {
|
||||
return {
|
||||
...n,
|
||||
parentId: proxmoxId,
|
||||
extent: 'parent' as const,
|
||||
position: {
|
||||
x: Math.max(10, n.position.x - parentNode.position.x),
|
||||
y: Math.max(10, n.position.y - parentNode.position.y),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (!enabled && parentNode) {
|
||||
return {
|
||||
...n,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: parentNode.position.x + n.position.x,
|
||||
y: parentNode.position.y + n.position.y,
|
||||
},
|
||||
}
|
||||
}
|
||||
return enabled
|
||||
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
|
||||
: { ...n, parentId: undefined, extent: undefined }
|
||||
|
||||
@@ -13,7 +13,8 @@ export type NodeType =
|
||||
| 'printer'
|
||||
| 'computer'
|
||||
| 'cpl'
|
||||
| 'docker'
|
||||
| 'docker_container'
|
||||
| 'docker_host'
|
||||
| 'generic'
|
||||
| 'groupRect'
|
||||
| 'group'
|
||||
@@ -126,7 +127,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
printer: 'Printer',
|
||||
computer: 'Computer',
|
||||
cpl: 'CPL / Powerline',
|
||||
docker: 'Docker Host',
|
||||
docker_container: 'Docker Container',
|
||||
docker_host: 'Docker Host',
|
||||
generic: 'Generic Device',
|
||||
groupRect: 'Group Rectangle',
|
||||
group: 'Node Group',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { maskIp, splitIps, primaryIp } from '../maskIp'
|
||||
import { maskIp } 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')
|
||||
})
|
||||
@@ -12,61 +11,9 @@ describe('maskIp', () => {
|
||||
expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX')
|
||||
})
|
||||
|
||||
// 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', () => {
|
||||
it('passes through non-IPv4 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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||
|
||||
const NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||
]
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||
@@ -84,7 +84,7 @@ describe('THEMES', () => {
|
||||
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
||||
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
||||
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
||||
expect(d.nodeAccents.docker.border).toBe('#2496ED')
|
||||
expect(d.nodeAccents.docker_host.border).toBe('#2496ED')
|
||||
expect(d.nodeCardBackground).toBe('#21262d')
|
||||
expect(d.nodeIconBackground).toBe('#161b22')
|
||||
expect(d.canvasBackground).toBe('#0d1117')
|
||||
|
||||
@@ -102,8 +102,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.measured?.width ?? n.width ?? null,
|
||||
height: n.measured?.height ?? n.height ?? null,
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
@@ -134,6 +134,7 @@ export function deserializeApiNode(
|
||||
n: ApiNode,
|
||||
proxmoxContainerMap: Map<string, boolean>,
|
||||
): Node<NodeData> {
|
||||
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
|
||||
if (n.type === 'groupRect') {
|
||||
const w = (n.custom_colors?.width as number | undefined) ?? 360
|
||||
const h = (n.custom_colors?.height as number | undefined) ?? 240
|
||||
@@ -152,12 +153,15 @@ export function deserializeApiNode(
|
||||
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
type: normalizedType,
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n as unknown as NodeData,
|
||||
data: { ...n, type: normalizedType } as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
...(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 } : {}),
|
||||
...(normalizedType === 'proxmox' && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: {}),
|
||||
...(n.width && normalizedType !== 'proxmox' ? { width: n.width } : {}),
|
||||
...(n.height && normalizedType !== 'proxmox' ? { height: n.height } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +1,10 @@
|
||||
/**
|
||||
* 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"
|
||||
* 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.
|
||||
*/
|
||||
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] ?? ''
|
||||
const parts = ip.split('.')
|
||||
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
|
||||
return ip
|
||||
}
|
||||
|
||||
@@ -132,7 +132,8 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
cpl: PlugZap,
|
||||
docker: Anchor,
|
||||
docker_container: Container,
|
||||
docker_host: Anchor,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
|
||||
@@ -56,7 +56,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
@@ -111,7 +112,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
@@ -166,7 +168,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
cpl: { border: '#b45309', icon: '#b45309' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
||||
group: { border: '#0284c7', icon: '#0284c7' },
|
||||
@@ -221,7 +224,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||
docker: { border: '#00aaff', icon: '#00aaff' },
|
||||
docker_container: { border: '#00ddff', icon: '#00ddff' },
|
||||
docker_host: { border: '#00aaff', icon: '#00aaff' },
|
||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
||||
group: { border: '#00ffff', icon: '#00ffff' },
|
||||
@@ -276,7 +280,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#005500', icon: '#005500' },
|
||||
computer: { border: '#008822', icon: '#008822' },
|
||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||
docker: { border: '#00cc88', icon: '#00cc88' },
|
||||
docker_container: { border: '#00dd99', icon: '#00dd99' },
|
||||
docker_host: { border: '#00cc88', icon: '#00cc88' },
|
||||
generic: { border: '#006600', icon: '#006600' },
|
||||
groupRect:{ border: '#00ff41', icon: '#00ff41' },
|
||||
group: { border: '#00ff41', icon: '#00ff41' },
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
|
||||
import pkg from './package.json'
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appVersion),
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
|
||||
Reference in New Issue
Block a user