Compare commits

..

9 Commits

Author SHA1 Message Date
Pouzor 8cf6aec987 feat: add docker_container node type with migrations and fixes
- Fix CONTAINER_MODE_TYPES to only include proxmox and docker_host (not vm/lxc)
- Add DB migration: existing proxmox nodes get container_mode=1 (they were always containers)
- Add DB migration: legacy 'docker' type renamed to 'docker_container'
- Update canvasStore tests: parent nodes need container_mode=true to nest children
- Fix updateNode test: pass absolute position so relative recalc yields expected coords
- Add backend tests for both migrations (test_docker_migration.py)
2026-04-19 00:08:32 +02:00
Remy ea6e3dc02e Merge branch 'main' into feature/node-type-docker 2026-04-18 23:13:45 +02:00
Brett Ferrante 2a79161106 Merge branch 'develop' into feature/node-type-docker 2026-04-17 20:40:37 -04:00
Brett Ferrante 97a7d7da3d Merge pull request #3 from findthelorax/feature/container-mode
feature: added a Container Mode toggle to the Virtualization group of node types
2026-04-17 20:31:56 -04:00
findthelorax 7e3abf8889 feature: added a docker container type 2026-04-16 13:48:10 -04:00
findthelorax 548da952c5 feature: added a Container Mode toggle to the Virtualization group of node types 2026-04-16 12:58:40 -04:00
Brett Ferrante fda5e6c16c Merge pull request #2 from findthelorax/feature/extended-zoom
feature: extend zoom out and border width math
2026-04-16 11:48:05 -04:00
findthelorax b64716c15c feature: extend zoom out and border width math 2026-04-16 11:44:24 -04:00
Brett Ferrante c842ff1ff3 Merge pull request #1 from findthelorax/feature/double-click-to-edit
feature: double-click on a node to open an edit modal
2026-04-16 11:32:03 -04:00
59 changed files with 509 additions and 2671 deletions
-276
View File
@@ -1,276 +0,0 @@
# Contributing to Homelable
Thanks for taking the time to contribute! This document covers everything you need to get started.
---
## Table of Contents
- [Ways to Contribute](#ways-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Features](#suggesting-features)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Testing](#testing)
- [Submitting a Pull Request](#submitting-a-pull-request)
- [Commit Message Format](#commit-message-format)
---
## Ways to Contribute
- Report bugs or unexpected behavior
- Suggest new features or improvements
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
- Improve documentation
- Add service signatures to `service_signatures.json`
---
## Reporting Bugs
Before opening an issue, search existing ones to avoid duplicates.
When filing a bug, include:
- **Homelable version** (visible in the sidebar bottom-left)
- **Deployment method** (Docker Compose, Proxmox LXC, source)
- **Steps to reproduce**
- **Expected vs actual behavior**
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
- **Browser console errors** if it's a UI issue
---
## Suggesting Features
Open an issue with the `enhancement` label. Describe:
- The problem you're trying to solve
- Your proposed solution
- Any alternatives you considered
For large changes, discuss first before writing code — it avoids wasted effort.
---
## Development Setup
### Prerequisites
- **Node.js 20+** and **npm**
- **Python 3.113.13** (3.14 not yet supported by all dependencies)
- **nmap** installed on your system (required for scanner)
- **Docker + Docker Compose** (optional, for full-stack testing)
### 1. Clone the repo
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
```
### 2. Backend
```bash
cd backend
python3.13 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# Copy and configure environment
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
# Start the backend (auto-reloads on change)
uvicorn app.main:app --reload --port 8000
```
API docs available at `http://localhost:8000/docs`.
### 3. Frontend
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
Vite proxies `/api` to `localhost:8000` — the backend must be running.
### 4. Verify tooling
```bash
./scripts/verify-tooling.sh
```
---
## Project Structure
```
homelable/
├── frontend/src/
│ ├── components/
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
│ │ ├── panels/ # Sidebar, detail panel, toolbar
│ │ ├── modals/ # Add/edit node, scan config, pending devices
│ │ └── ui/ # Shadcn/ui base components
│ ├── stores/ # Zustand state (canvas, auth, scan)
│ ├── hooks/ # Custom React hooks
│ ├── types/ # TypeScript interfaces & enums
│ ├── api/ # Axios client & typed endpoints
│ └── utils/ # Layout, export, color helpers
├── backend/app/
│ ├── api/routes/ # FastAPI route handlers
│ ├── services/ # Scanner, status checker, canvas service
│ ├── db/ # SQLAlchemy models, Alembic migrations
│ ├── schemas/ # Pydantic request/response schemas
│ └── core/ # Config, JWT, scheduler
├── docker/ # Nginx configs
├── scripts/ # LXC bootstrap, dev helpers
└── mcp/ # MCP server (AI integration)
```
---
## Coding Standards
### General
- No untested code merged — every feature or fix must include tests
- Keep changes focused — one concern per PR
### Frontend (TypeScript + React)
- Strict TypeScript — no `any`, no type assertions unless truly necessary
- React Flow node domain fields go in `node.data`, never on the node root
- State management via Zustand stores — no prop drilling beyond 2 levels
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
- Run before committing:
```bash
cd frontend
npm run lint
npm run typecheck
npm test
```
### Backend (Python + FastAPI)
- Python 3.11+ syntax
- Pydantic v2 schemas for all request/response types
- SQLAlchemy async sessions — never block the event loop
- Scanner logic runs in a background thread — never in an async route directly
- All schema changes via Alembic migrations — never modify tables directly
- Run before committing:
```bash
cd backend
source .venv/bin/activate
ruff check .
pytest
```
### Design System
| Token | Value |
|---|---|
| Background | `#0d1117` |
| Surface | `#161b22` |
| Card | `#21262d` |
| Accent cyan | `#00d4ff` |
| Online | `#39d353` |
| Offline | `#f85149` |
| Pending | `#e3b341` |
| Font (UI) | Inter |
| Font (IPs/ports) | JetBrains Mono |
---
## Testing
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
### Frontend
```bash
cd frontend
npm test # run all tests
npm run test:coverage # with coverage report
```
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
### Backend
```bash
cd backend
source .venv/bin/activate
pytest # run all tests
pytest -v tests/test_nodes.py # single file
```
Test files live in `backend/tests/test_*.py`.
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
---
## Submitting a Pull Request
1. **Fork** the repo and create a branch from `main`:
```bash
git checkout -b feat/my-feature
```
2. **Make your changes** — include tests.
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
4. **Open a PR** against `main`:
- Use a clear title (see commit format below)
- Describe what changed and why
- Reference any related issues (`Closes #123`)
- Include screenshots for UI changes
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
---
## Commit Message Format
Follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>: <short description>
[optional body]
```
| Type | When to use |
|---|---|
| `feat` | New feature |
| `fix` | Bug fix |
| `docs` | Documentation only |
| `refactor` | Code change with no behavior change |
| `test` | Adding or fixing tests |
| `chore` | Build, deps, tooling |
**Examples:**
```
feat: add logout button to sidebar
fix: stop click propagation on pending device checkbox
docs: add CONTRIBUTING.md
```
---
## Questions?
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
+2 -3
View File
@@ -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
-1
View File
@@ -12,7 +12,6 @@ COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ .
COPY VERSION ../VERSION
RUN npm run build
# Stage 2: serve
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Remy Jardinet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-1
View File
@@ -1 +0,0 @@
1.11.0
+14 -94
View File
@@ -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]
@@ -41,15 +37,7 @@ router = APIRouter()
async def _background_scan(run_id: str, ranges: list[str]) -> None:
async with AsyncSessionLocal() as db:
try:
await run_scan(ranges, db, run_id)
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run and run.status == "running":
run.status = "failed"
await db.commit()
await run_scan(ranges, db, run_id)
@router.post("/trigger", response_model=ScanRunResponse)
@@ -97,10 +85,12 @@ async def clear_pending(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, int]:
from sqlalchemy import delete as sa_delete
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
devices = result.scalars().all()
for device in devices:
await db.delete(device)
await db.commit()
return {"deleted": result.rowcount}
return {"deleted": len(devices)}
@router.get("/hidden", response_model=list[PendingDeviceResponse])
@@ -109,63 +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()
created_nodes: list[Node] = []
for device in devices:
device.status = "approved"
node = Node(
label=device.hostname or device.ip,
type=device.suggested_type or "generic",
ip=device.ip,
hostname=device.hostname,
status="unknown",
services=device.services or [],
)
db.add(node)
created_nodes.append(node)
await db.flush() # populates node.id from Python-side default before reading
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in devices]
await db.commit()
return {
"approved": len(node_ids),
"node_ids": node_ids,
"device_ids": approved_device_ids,
"skipped": len(payload.device_ids) - len(node_ids),
}
@router.post("/pending/bulk-hide", response_model=dict)
async def bulk_hide_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
)
)
devices = result.scalars().all()
for device in devices:
device.status = "hidden"
await db.commit()
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
@router.post("/pending/{device_id}/approve", response_model=dict)
async def approve_device(
device_id: str,
@@ -174,24 +107,13 @@ async def approve_device(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
status=node_data.status,
services=node_data.services or [],
)
db.add(node)
await db.flush()
node_id = node.id
await db.commit()
return {"approved": True, "node_id": node_id}
if device:
device.status = "approved"
node = Node(**node_data.model_dump())
db.add(node)
await db.commit()
return {"approved": True, "node_id": node.id}
return {"approved": False}
@router.post("/pending/{device_id}/hide")
@@ -231,12 +153,10 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
previous = settings.scanner_ranges
settings.scanner_ranges = payload.ranges
try:
settings.scanner_ranges = payload.ranges
settings.save_overrides()
return payload
except Exception as exc:
settings.scanner_ranges = previous
logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
-11
View File
@@ -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
View File
@@ -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]:
+1 -3
View File
@@ -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}
-1
View File
@@ -2,4 +2,3 @@
*.db-shm
*.db-wal
scan_config.json
homelab.db.*
-9
View File
@@ -453,15 +453,6 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head
assert node["notes"] == "My NAS device"
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
n1 = node_payload(ip="192.168.1.50:8080", services=services)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["services"] == services
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
-57
View File
@@ -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()
+96
View File
@@ -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'
+2 -100
View File
@@ -120,7 +120,8 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
json=node_payload,
headers=headers,
)
assert res.status_code == 404
assert res.status_code == 200
assert res.json()["approved"] is False
# --- Hide device ---
@@ -443,102 +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 all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
assert len(data["device_ids"]) == 2
assert data["skipped"] == 0
# Pending list should now be empty
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
assert pending_res.json() == []
@pytest.mark.asyncio
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Approve first device individually first
await client.post(
f"/api/v1/scan/pending/{ids[0]}/approve",
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
headers=headers,
)
# Bulk approve both — first one is already approved (not pending), should be skipped
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
assert res.status_code == 401
# --- Bulk hide ---
@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 2
assert data["skipped"] == 0
# Should appear in hidden list
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
assert len(hidden_res.json()) == 2
@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
# Hide first device individually first
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
# Bulk hide both — first is already hidden (not pending anymore)
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["hidden"] == 1
assert data["skipped"] == 1
@pytest.mark.asyncio
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
+3 -413
View File
@@ -1,19 +1,18 @@
{
"name": "frontend",
"version": "1.10.2",
"version": "1.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.10.2",
"version": "1.8.2",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
@@ -2012,415 +2011,6 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -3434,7 +3024,7 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.11.0",
"version": "1.9.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -19,7 +19,6 @@
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1",
"axios": "^1.13.6",
+20 -27
View File
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportToPng } from '@/utils/export'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -33,7 +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', 'vm', 'lxc', 'docker_host'])
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()
@@ -54,7 +54,6 @@ export default function App() {
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => {
@@ -101,7 +100,7 @@ export default function App() {
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
// Build a map of container mode nodes to know if children should be nested
// 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 === 'group' || n.container_mode === true)
@@ -152,7 +151,7 @@ export default function App() {
const handleAddNode = useCallback((data: Partial<NodeData>) => {
snapshotHistory()
const id = generateUUID()
const isContainerNode = data.container_mode === true
const isProxmox = data.type === 'proxmox'
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
// Children position is relative to parent; place near top-left with padding
const position = parentNode
@@ -165,7 +164,7 @@ export default function App() {
position,
data: { status: 'unknown', services: [], ...data } as NodeData,
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
...(isContainerNode ? { width: 300, height: 200 } : {}),
...(isProxmox ? { width: 300, height: 200 } : {}),
}
addNode(newNode)
toast.success(`Added "${data.label}"`)
@@ -249,7 +248,7 @@ export default function App() {
}
// Sync virtual edge when parent_id changes on an LXC/VM node
const nodeType = data.type ?? existingNode?.data.type
if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) {
if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) {
const oldParentId = existingNode?.data.parent_id ?? null
const newParentId = data.parent_id ?? null
if (oldParentId !== newParentId) {
@@ -307,10 +306,15 @@ export default function App() {
}
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
const handleExport = useCallback(() => {
const handleExport = useCallback(async () => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
setExportModalOpen(true)
try {
await exportToPng(el)
toast.success('Exported as PNG')
} catch {
toast.error('Export failed')
}
}, [])
const handleEdgeConnect = useCallback((connection: Connection) => {
@@ -321,19 +325,15 @@ export default function App() {
if (!pendingConnection) return
snapshotHistory()
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
// When a virtual edge is drawn between a child node and a container node, sync parent_id
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
if (edgeData.type === 'virtual') {
const src = nodes.find((n) => n.id === pendingConnection.source)
const tgt = nodes.find((n) => n.id === pendingConnection.target)
const srcType = src?.data.type as NodeData['type']
const tgtType = tgt?.data.type as NodeData['type']
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
const srcType = src?.data.type
const tgtType = tgt?.data.type
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
}
}
@@ -423,14 +423,13 @@ export default function App() {
</div>
<NodeModal
key={addNodeOpen ? 'add-open' : 'add-closed'}
open={addNodeOpen}
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
parentContainerNodes={nodes
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
.map((n) => ({ id: n.id, label: n.data.label }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -443,7 +442,7 @@ export default function App() {
title="Edit Node"
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, nodeType: n.data.type }))}
.map((n) => ({ id: n.id, label: n.data.label }))}
/>
<EdgeModal
@@ -537,12 +536,6 @@ export default function App() {
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<ExportModal
open={exportModalOpen}
onClose={() => setExportModalOpen(false)}
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
/>
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
</TooltipProvider>
-2
View File
@@ -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,
@@ -66,7 +66,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
minWidth={140}
minHeight={50}
lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
/>
<Handle
type="source"
@@ -76,15 +76,8 @@ 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 }} />
{/* Status dot — absolute to avoid affecting node auto-width */}
<div
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
{/* 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"
@@ -105,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>
@@ -122,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-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate" title={prop.value}>· {prop.value}</span>
</div>
)
})}
@@ -146,7 +138,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" />
{data.cpu_model && (
<span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
)}
{data.cpu_count != null && (
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
@@ -173,6 +165,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</>
)}
{/* Status dot */}
<div
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
const sourceId = BOTTOM_HANDLE_IDS[idx]
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
@@ -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)
@@ -1,12 +1,7 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { BaseNode } from './BaseNode'
@@ -15,7 +10,6 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, activeTheme)
@@ -47,7 +41,6 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const isOnline = data.status === 'online'
const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
return (
<>
@@ -56,7 +49,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
minHeight={160}
isVisible={selected}
lineStyle={{ borderColor: glow, opacity: 0.6 }}
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
/>
{/* Group border */}
@@ -74,7 +67,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
>
{/* Header bar */}
<div
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
style={{
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
@@ -87,7 +80,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 12 })}
<Layers size={12} />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span
@@ -96,45 +89,23 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
>
{data.label}
</span>
{data.ip && splitIps(data.ip).map((ip) => (
{data.ip && (
<span
key={ip}
className="font-mono text-[9px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
>
{hideIp ? maskIp(ip) : ip}
{data.ip}
</span>
))}
)}
</div>
{/* Status dot */}
<div
className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
</div>
{/* Properties */}
{data.properties?.filter((p) => p.visible).map((prop, i, arr) => {
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 px-2.5 shrink-0"
style={{
color: theme.colors.nodeSubtextColor,
paddingTop: i === 0 ? 4 : 2,
paddingBottom: i === arr.length - 1 ? 4 : 2,
borderTop: i === 0 ? `1px solid ${glow}22` : undefined,
}}
>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div>
)
})}
{/* Inner area — React Flow places children here */}
<div className="flex-1 relative" />
</div>
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -22,6 +22,6 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
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 DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
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, DockerHostNode, DockerContainerNode, 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,8 +18,8 @@ export const nodeTypes = {
printer: PrinterNode,
computer: ComputerNode,
cpl: CplNode,
docker_host: DockerHostNode,
docker_container: DockerContainerNode,
docker_host: DockerNode,
generic: GenericNode,
groupRect: GroupRectNode,
group: GroupNode,
@@ -1,71 +0,0 @@
import { useState } from 'react'
import { Download, Loader2 } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { exportToPng, EXPORT_QUALITY_OPTIONS, type ExportQuality } from '@/utils/export'
interface ExportModalProps {
open: boolean
onClose: () => void
getElement: () => HTMLElement | null
}
export function ExportModal({ open, onClose, getElement }: ExportModalProps) {
const [quality, setQuality] = useState<ExportQuality>('high')
const [exporting, setExporting] = useState(false)
const handleExport = async () => {
const el = getElement()
if (!el) return
setExporting(true)
try {
await exportToPng(el, quality)
onClose()
} finally {
setExporting(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="bg-[#161b22] border-border max-w-sm">
<DialogHeader>
<DialogTitle className="text-foreground">Export as PNG</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
{EXPORT_QUALITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setQuality(opt.value)}
className={[
'w-full flex items-center justify-between px-3 py-2.5 rounded-md border text-sm transition-colors',
quality === opt.value
? 'border-[#00d4ff] bg-[#00d4ff10] text-foreground'
: 'border-border bg-[#0d1117] text-muted-foreground hover:border-muted-foreground',
].join(' ')}
>
<span className="font-medium">{opt.label}</span>
<span className="text-xs opacity-70">{opt.hint}</span>
</button>
))}
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose} disabled={exporting}>Cancel</Button>
<Button
onClick={handleExport}
disabled={exporting}
style={{ background: '#00d4ff', color: '#0d1117' }}
>
{exporting
? <><Loader2 size={14} className="animate-spin mr-1.5" />Exporting</>
: <><Download size={14} className="mr-1.5" />Download</>
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
@@ -205,35 +204,23 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => {
const { hex6, alpha } = hexToRgba(form[key])
return (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="color"
value={hex6}
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="range"
min={0}
max={100}
value={alpha}
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
title={`Opacity: ${alpha}%`}
type="color"
value={form[key]}
onChange={(e) => set(key, e.target.value)}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
</div>
)
})}
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<span className="text-[9px] text-muted-foreground/60">{label}</span>
</div>
))}
</div>
</div>
+12 -31
View File
@@ -19,17 +19,6 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
ping: 'Ping',
http: 'HTTP',
https: 'HTTPS',
tcp: 'TCP',
ssh: 'SSH',
prometheus: 'Prometheus',
health: 'Health',
}
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
label: '',
@@ -49,7 +38,7 @@ interface NodeModalProps {
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
parentContainerNodes?: { id: string; label: string }[]
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
@@ -79,10 +68,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
onClose()
}
const filteredParentNodes = form.type === 'docker_container'
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
: parentContainerNodes
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
@@ -228,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>
@@ -242,11 +227,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
))}
</SelectContent>
</Select>
@@ -264,7 +249,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
{/* Parent container */}
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
{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 Container</Label>
<Select
@@ -272,15 +257,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue placeholder="None (standalone)">
{form.parent_id
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
: 'None (standalone)'}
</SelectValue>
<SelectValue placeholder="None (standalone)" />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{filteredParentNodes.map((n) => (
{parentContainerNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
@@ -300,12 +281,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
role="switch"
aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
</div>
@@ -404,4 +385,4 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</DialogContent>
</Dialog>
)
}
}
@@ -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>
@@ -1,74 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ExportModal } from '../ExportModal'
const mockExportToPng = vi.fn()
vi.mock('@/utils/export', () => ({
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
EXPORT_QUALITY_OPTIONS: [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
],
}))
const el = document.createElement('div')
const getElement = () => el
const onClose = vi.fn()
describe('ExportModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportToPng.mockResolvedValue(undefined)
})
it('renders all three quality options', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
expect(screen.getByText('Standard')).toBeInTheDocument()
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.getByText('Ultra')).toBeInTheDocument()
})
it('selects High by default', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
const highBtn = screen.getByText('High').closest('button')!
expect(highBtn.className).toContain('border-[#00d4ff]')
})
it('changes selection when another option is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Ultra').closest('button')!)
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]')
})
it('calls exportToPng with selected quality on Download click', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Standard').closest('button')!)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
})
it('closes after successful export', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('calls onClose when Cancel is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalled()
})
it('does not call exportToPng when getElement returns null', async () => {
render(<ExportModal open onClose={onClose} getElement={() => null} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled())
})
it('does not render when closed', () => {
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
})
})
@@ -251,60 +251,4 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
it('shows opacity sliders for all three color fields', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
const sliders = screen.getAllByRole('slider')
expect(sliders).toHaveLength(3)
})
it('default background_color is 8-digit hex with low alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.background_color).toBe('#00d4ff0d')
expect(submitted.background_color.length).toBe(9)
})
it('moving background opacity slider updates background_color alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// background slider is the third one (Text, Border, Background)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[2], { target: { value: '50' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
// alpha 50% → 0x80 = 128
expect(submitted.background_color).toBe('#00d4ff80')
})
it('moving border opacity slider to 0 makes border fully transparent', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[1], { target: { value: '0' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_color).toBe('#00d4ff00')
})
it('pre-fills opacity from 8-digit initial background_color', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ background_color: '#ff6e0080' }}
/>
)
const sliders = screen.getAllByRole('slider')
expect((sliders[2] as HTMLInputElement).value).toBe('50')
})
it('shows opacity percentage in label', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
// Background default is 5% opacity
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
})
})
@@ -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>
@@ -130,21 +130,6 @@ describe('NodeModal', () => {
expect(data.notes).toBe('rack A')
})
it('resets form values when reopened in Add mode', () => {
const onClose = vi.fn()
const onSubmit = vi.fn()
const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
})
it('submits check_target', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
@@ -236,16 +221,18 @@ describe('NodeModal', () => {
// ── Container mode ─────────────────────────────────────────────────────
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
it('shows Container Mode toggle for proxmox type', () => {
renderModal({ initial: { ...BASE, type: 'proxmox' } })
expect(screen.getByText('Container Mode')).toBeDefined()
})
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
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()
})
@@ -258,45 +245,22 @@ describe('NodeModal', () => {
// ── Parent container ──────────────────────────────────────────────────
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
it('shows Parent Container when options are provided', () => {
renderModal({
initial: { ...BASE, type },
initial: { ...BASE, type: 'server' },
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
expect(screen.getByText('Container 01')).toBeDefined()
})
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
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.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('docker_container shows only docker_host parents', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentContainerNodes: [
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
],
})
expect(screen.getByText('My Docker Host')).toBeDefined()
expect(screen.queryByText('My Proxmox')).toBeNull()
})
it('docker_container hides Parent Container when no docker_host is available', () => {
renderModal({
initial: { ...BASE, type: 'docker_container' },
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
})
it('hides Parent Container when no container options are available', () => {
renderModal({ initial: { ...BASE, type: 'server' } })
expect(screen.queryByText('Parent Container')).toBeNull()
})
+27 -145
View File
@@ -2,11 +2,9 @@ import { createElement, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
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'
@@ -14,8 +12,8 @@ interface DetailPanelProps {
onEdit: (id: string) => void
}
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
@@ -95,18 +93,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
}
const handleAddService = () => {
const trimmedPort = newSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
const port = parseInt(newSvc.port, 10)
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
snapshotHistory()
const path = newSvc.path.trim()
const svc: ServiceInfo = {
...(port != null ? { port } : {}),
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
}
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM)
setAddingForNode(null)
@@ -122,29 +112,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleStartEdit = (index: number) => {
const svc = services[index]
if (!svc) return
setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
setEditingFor({ nodeId: node.id, index })
setAddingForNode(null)
}
const handleSaveEdit = () => {
if (editingIndex === null) return
const trimmedPort = editSvc.port.trim()
const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
const port = parseInt(editSvc.port, 10)
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) =>
i === editingIndex
? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
)
updateNode(node.id, { services: updated })
setEditingFor(null)
@@ -223,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 />}
@@ -300,7 +272,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : (
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
)
)}
</div>
@@ -500,46 +472,23 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
}
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
autoFocus?: boolean
}) {
const setPort = (value: string) => {
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
onChange({ ...form, port: digitsOnly })
}
const clampPort = (value: string) => {
if (!value) return ''
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return ''
return String(Math.max(1, Math.min(65535, parsed)))
}
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
@@ -662,87 +611,20 @@ const CATEGORY_COLORS: Record<string, string> = {
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const hasPort = svc.port != null
const portLabel = hasPort ? String(svc.port) : ''
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
return (
<div
className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
style={{ background: '#21262d', borderColor: '#30363d' }}
>
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium truncate min-w-0 flex-1"
style={{ color }}
title={svc.service_name}
onClick={e => e.stopPropagation()}
>
{svc.service_name}
</a>
) : (
<span
className="font-medium truncate min-w-0 flex-1"
style={{ color }}
title={svc.service_name}
>
{svc.service_name}
</span>
)}
<div className="flex items-center gap-1 shrink-0">
{pathLabel && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className="truncate text-[#8b949e] max-w-[80px]"
tabIndex={0}
aria-label={pathLabel}
>
{pathLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">{pathLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasPort && (
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
)}
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
aria-label="Open service link"
style={{ color: 'inherit' }}
onClick={e => e.stopPropagation()}
>
<ExternalLink size={10} className="text-muted-foreground" />
</a>
) : (
<span className="w-2.5 shrink-0" />
)}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
title="Edit service"
>
<Pencil size={10} />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
title="Remove service"
>
<X size={10} />
</button>
const inner = (
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
<div className="flex items-center gap-1.5 min-w-0">
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />}
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
</div>
</div>
)
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
return inner
}
+7 -114
View File
@@ -1,9 +1,8 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
@@ -44,7 +43,6 @@ interface SidebarProps {
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
const logout = useAuthStore((s) => s.logout)
// When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed
@@ -154,14 +152,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/>
)}
{!STANDALONE && (
<SidebarItem
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
/>
)}
</div>
{!collapsed && <VersionBadge />}
@@ -175,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 {
@@ -211,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(() => {
@@ -324,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} />
@@ -348,28 +263,12 @@ 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>
)}
{devices.map((d) => {
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
const titleService = namedService
?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443)
@@ -389,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) => e.stopPropagation()}
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
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 && (
@@ -637,7 +530,7 @@ function SettingsPanel() {
min={10}
max={3600}
value={interval}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
onChange={(e) => setIntervalValue(Number(e.target.value))}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
/>
<span className="text-xs text-muted-foreground">seconds</span>
@@ -674,7 +567,7 @@ function VersionBadge() {
</a>
{hasUpdate && latest && (
<a
href={latest.url.startsWith('https://') ? latest.url : '#'}
href={latest.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
@@ -2,11 +2,10 @@ import { describe, it, expect } from 'vitest'
import { getServiceUrl } from '@/utils/serviceUrl'
import type { ServiceInfo } from '@/types'
const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
...(port != null ? { port } : {}),
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
port,
protocol,
service_name,
...(path ? { path } : {}),
})
describe('getServiceUrl', () => {
@@ -64,20 +63,4 @@ describe('getServiceUrl', () => {
it('uses host string directly (works with both IP and hostname)', () => {
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
})
it('uses the node port when the host already includes one', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
})
it('lets the service port override the node port', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
})
it('appends a normalized path to the final URL', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
})
it('supports path-only services inheriting the node port', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
})
})
@@ -293,35 +293,9 @@ describe('DetailPanel', () => {
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
it('allows adding a service without a port', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
})
it('calls updateNode without the removed service when X is clicked', () => {
@@ -358,7 +332,7 @@ describe('DetailPanel', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [{ ...svc, path: '/admin' }] })
setupStore({ services: [svc] })
render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service')
@@ -367,8 +341,6 @@ describe('DetailPanel', () => {
expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80')
const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement
expect(pathInput.value).toBe('/admin')
})
it('calls updateNode with updated values on Save', () => {
@@ -387,13 +359,11 @@ describe('DetailPanel', () => {
const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
})
it('cancels edit without updating', () => {
@@ -415,76 +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()
})
})
describe('ServiceBadge rendering', () => {
it('renders service name and port/protocol label', () => {
setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('nginx')).toBeDefined()
expect(screen.getByText('8080/tcp')).toBeDefined()
})
it('renders path label when path is set', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('/admin')).toBeDefined()
})
it('renders no path text when path is empty', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('/')).toBeNull()
})
it('renders port/protocol omitted when port is absent', () => {
setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health')).toBeDefined()
expect(screen.queryByText(/\/tcp/)).toBeNull()
})
it('renders service name as link when ip and port are set', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: 'nginx' })
expect(link.getAttribute('href')).toContain('192.168.1.10')
expect(link.getAttribute('target')).toBe('_blank')
})
it('renders service name as plain text when no url can be built', () => {
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').tagName).not.toBe('A')
})
})
})
@@ -2,17 +2,12 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
@@ -21,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 } }),
@@ -62,7 +51,6 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
})
const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
@@ -76,12 +64,6 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
} as ReturnType<typeof useCanvasStore>)
}
function mockAuth() {
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
)
}
const defaultProps = {
onAddNode: vi.fn(),
onAddGroupRect: vi.fn(),
@@ -95,7 +77,6 @@ const defaultProps = {
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
mockAuth()
vi.clearAllMocks()
})
@@ -277,115 +258,4 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
})
// ── Logout ─────────────────────────────────────────────────────────────────
it('shows Logout button in normal mode', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
})
})
// ── 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()
mockAuth()
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())
})
})
+50 -16
View File
@@ -1,30 +1,64 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
"use client"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className={[
'z-50 overflow-hidden rounded-md border border-[#30363d] bg-[#161b22] px-2 py-1 text-xs text-[#e6edf3] shadow-md',
'animate-in fade-in-0 zoom-in-95',
className,
]
.filter(Boolean)
.join(' ')}
{...props}
className="isolate z-50"
>
{children}
</TooltipPrimitive.Content>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
@@ -64,17 +64,6 @@ describe('canvasStore', () => {
expect(nested?.extent).toBe('parent')
})
it('docker_container nests under docker_host with container_mode on', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(host)
useCanvasStore.getState().addNode(container)
useCanvasStore.getState().updateNode('dc1', { parent_id: 'dh1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'dc1')
expect(node?.parentId).toBe('dh1')
expect(node?.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' })
@@ -248,7 +237,7 @@ describe('canvasStore', () => {
expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
})
it('addNode with parent_id sets parentId and extent when parent is in container mode', () => {
it('addNode with parent_id sets parentId and extent', () => {
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')
@@ -448,26 +437,6 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true)
})
it('setProxmoxContainerMode ON sets width/height for docker_host (not just proxmox)', () => {
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: false } }
useCanvasStore.setState({ nodes: [host] })
useCanvasStore.getState().setProxmoxContainerMode('dh', true)
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
expect(updated?.data.container_mode).toBe(true)
expect(updated?.width).toBe(300)
expect(updated?.height).toBe(200)
})
it('setProxmoxContainerMode OFF clears width/height for docker_host', () => {
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, width: 300, height: 200, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: true } }
useCanvasStore.setState({ nodes: [host] })
useCanvasStore.getState().setProxmoxContainerMode('dh', false)
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
expect(updated?.data.container_mode).toBe(false)
expect(updated?.width).toBeUndefined()
expect(updated?.height).toBeUndefined()
})
it('setProxmoxContainerMode ON nests children inside proxmox', () => {
const proxmox: Node<NodeData> = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } }
const child = makeNode('vm1', { parent_id: 'px', type: 'vm' })
+1
View File
@@ -297,6 +297,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
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: n.width ?? 300, height: n.height ?? 200 }
: { ...withMode, width: undefined, height: undefined }
+3 -4
View File
@@ -13,8 +13,8 @@ export type NodeType =
| 'printer'
| 'computer'
| 'cpl'
| 'docker_host'
| 'docker_container'
| 'docker_host'
| 'generic'
| 'groupRect'
| 'group'
@@ -37,10 +37,9 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
export interface ServiceInfo {
port?: number
port: number
protocol: 'tcp' | 'udp'
service_name: string
path?: string
icon?: string
category?: string
}
@@ -128,8 +127,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer',
computer: 'Computer',
cpl: 'CPL / Powerline',
docker_host: 'Docker Host',
docker_container: 'Docker Container',
docker_host: 'Docker Host',
generic: 'Generic Device',
groupRect: 'Group Rectangle',
group: 'Node Group',
@@ -1,97 +0,0 @@
import { describe, it, expect } from 'vitest'
import { hexToRgba, rgbaToHex8 } from '../colorUtils'
describe('hexToRgba', () => {
it('splits 8-digit hex into hex6 and alpha', () => {
const { hex6, alpha } = hexToRgba('#00d4ff0d')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(5)
})
it('handles fully opaque 8-digit hex (ff)', () => {
const { hex6, alpha } = hexToRgba('#00d4ffff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles fully transparent 8-digit hex (00)', () => {
const { hex6, alpha } = hexToRgba('#00d4ff00')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(0)
})
it('defaults alpha to 100 for 6-digit hex', () => {
const { hex6, alpha } = hexToRgba('#00d4ff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles 6-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e00')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(100)
})
it('handles 8-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e0080')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(50)
})
it('returns fallback for invalid input', () => {
const { hex6, alpha } = hexToRgba('invalid')
expect(hex6).toBe('#000000')
expect(alpha).toBe(100)
})
it('is case-insensitive', () => {
const { hex6 } = hexToRgba('#00D4FF0D')
expect(hex6).toBe('#00D4FF')
})
})
describe('rgbaToHex8', () => {
it('combines hex6 and alpha into 8-digit hex', () => {
expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
})
it('produces ff for alpha 100', () => {
expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
})
it('produces 00 for alpha 0', () => {
expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
})
it('produces 80 for alpha 50', () => {
expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
})
it('clamps alpha below 0 to 0', () => {
expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
})
it('clamps alpha above 100 to 100', () => {
expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
})
it('pads single-digit alpha hex with leading zero', () => {
const result = rgbaToHex8('#000000', 1)
const alphaPart = result.slice(7)
expect(alphaPart.length).toBe(2)
})
})
describe('round-trip', () => {
it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
const original = '#00d4ff0d'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
it('round-trips fully opaque color', () => {
const original = '#a855f7ff'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
})
@@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export'
const mockToPng = vi.fn()
vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) }))
describe('exportToPng', () => {
let el: HTMLElement
let clickSpy: ReturnType<typeof vi.fn>
let appendSpy: ReturnType<typeof vi.spyOn>
let createSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
el = document.createElement('div')
clickSpy = vi.fn()
createSpy = vi.spyOn(document, 'createElement').mockReturnValue(
Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement
)
appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n)
mockToPng.mockResolvedValue('data:image/png;base64,abc')
})
afterEach(() => {
createSpy.mockRestore()
appendSpy.mockRestore()
})
it('calls toPng with pixelRatio 1 for standard quality', async () => {
await exportToPng(el, 'standard')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 }))
})
it('calls toPng with pixelRatio 2 for high quality', async () => {
await exportToPng(el, 'high')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
})
it('calls toPng with pixelRatio 4 for ultra quality', async () => {
await exportToPng(el, 'ultra')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 }))
})
it('defaults to high quality when no quality arg given', async () => {
await exportToPng(el)
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
})
it('triggers a download with the correct filename', async () => {
await exportToPng(el, 'high')
expect(clickSpy).toHaveBeenCalled()
})
it('passes dark background color', async () => {
await exportToPng(el, 'standard')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' }))
})
})
describe('EXPORT_QUALITY_OPTIONS', () => {
it('has exactly three options', () => {
expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3)
})
it('options are standard, high, ultra in order', () => {
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra'])
})
it('pixel ratios are 1, 2, 4', () => {
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4])
})
})
+3 -56
View File
@@ -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('')
})
})
@@ -1,17 +1,12 @@
import { describe, it, expect } from 'vitest'
import { CircuitBoard, Cpu, EthernetPort, Gpu, HardDrive, HdmiPort, MemoryStick, Usb } from 'lucide-react'
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
describe('PROPERTY_ICONS', () => {
it('contains the hardware migration icons', () => {
expect(PROPERTY_ICONS['CircuitBoard']).toBe(CircuitBoard)
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
expect(PROPERTY_ICONS['EthernetPort']).toBe(EthernetPort)
expect(PROPERTY_ICONS['Gpu']).toBe(Gpu)
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
expect(PROPERTY_ICONS['HdmiPort']).toBe(HdmiPort)
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
expect(PROPERTY_ICONS['Usb']).toBe(Usb)
})
it('has at least 10 icons', () => {
+5 -5
View File
@@ -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,
@@ -157,11 +157,11 @@ export function deserializeApiNode(
position: { x: n.pos_x, y: n.pos_y },
data: { ...n, type: normalizedType } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
...(normalizedType === 'proxmox' && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
: {}),
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}),
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}),
...(n.width && normalizedType !== 'proxmox' ? { width: n.width } : {}),
...(n.height && normalizedType !== 'proxmox' ? { height: n.height } : {}),
}
}
-29
View File
@@ -1,29 +0,0 @@
/**
* Split a 6- or 8-digit hex color into its RGB hex and alpha (0100).
* 6-digit input returns alpha 100.
* Invalid input returns { hex6: '#000000', alpha: 100 }.
*/
export function hexToRgba(hex: string): { hex6: string; alpha: number } {
const clean = hex.replace('#', '')
if (clean.length === 8) {
const alphaByte = parseInt(clean.slice(6, 8), 16)
return {
hex6: `#${clean.slice(0, 6)}`,
alpha: Math.round((alphaByte / 255) * 100),
}
}
if (clean.length === 6) {
return { hex6: `#${clean}`, alpha: 100 }
}
return { hex6: '#000000', alpha: 100 }
}
/**
* Combine a 6-digit hex color and an alpha (0100) into an 8-digit hex.
*/
export function rgbaToHex8(hex6: string, alpha: number): string {
const clamped = Math.max(0, Math.min(100, alpha))
const alphaByte = Math.round((clamped / 100) * 255)
const alphaHex = alphaByte.toString(16).padStart(2, '0')
return `${hex6}${alphaHex}`
}
+6 -11
View File
@@ -1,19 +1,14 @@
import { toPng } from 'html-to-image'
export type ExportQuality = 'standard' | 'high' | 'ultra'
export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
]
export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise<void> {
const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1]
/**
* Export the React Flow canvas as a PNG and trigger a browser download.
* Pass the `.react-flow` wrapper element.
*/
export async function exportToPng(element: HTMLElement): Promise<void> {
const dataUrl = await toPng(element, {
backgroundColor: '#0d1117',
pixelRatio: option.pixelRatio,
style: {
// Exclude controls from the export
'--xy-controls-display': 'none',
} as Partial<CSSStyleDeclaration>,
})
+1 -5
View File
@@ -15,11 +15,7 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
.map((n) => {
const d = n.data
const services = d.services?.length
? d.services.map((s) => {
const port = s.port != null ? `:${s.port}` : ''
const path = s.path?.trim() ? s.path.trim() : ''
return `${s.service_name}${port}${path}`
}).join(', ')
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
: EMPTY
return [
cell(d.label),
+6 -41
View File
@@ -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
}
+59 -59
View File
@@ -32,21 +32,21 @@ export interface IconEntry {
export const ICON_REGISTRY: IconEntry[] = [
// --- Infrastructure ---
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
// --- Media ---
{ key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play },
@@ -62,44 +62,44 @@ export const ICON_REGISTRY: IconEntry[] = [
{ key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv },
// --- Monitoring ---
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
{ key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye },
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
// --- Storage & Databases ---
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
{ key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download },
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
// --- Security & Auth ---
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
{ key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock },
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
// --- Automation & Smart Home ---
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
// --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host / Container', category: 'Dev & Containers', icon: Package },
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
@@ -118,25 +118,25 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
)
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe,
router: Router,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker_host: Anchor,
docker_container: Package,
generic: Circle,
group: Circle,
groupRect: Circle,
isp: Globe,
router: Router,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker_container: Container,
docker_host: Anchor,
generic: Circle,
group: Circle,
groupRect: Circle,
}
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
-10
View File
@@ -1,15 +1,11 @@
import {
Battery,
Box,
CircuitBoard,
Clock,
Cpu,
Database,
EthernetPort,
Globe,
Gpu,
HardDrive,
HdmiPort,
Hash,
Key,
Layers,
@@ -21,7 +17,6 @@ import {
Shield,
Tag,
Thermometer,
Usb,
Wifi,
Zap,
} from 'lucide-react'
@@ -30,15 +25,11 @@ import type { LucideIcon } from 'lucide-react'
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Battery,
Box,
CircuitBoard,
Clock,
Cpu,
Database,
EthernetPort,
Globe,
Gpu,
HardDrive,
HdmiPort,
Hash,
Key,
Layers,
@@ -50,7 +41,6 @@ export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Shield,
Tag,
Thermometer,
Usb,
Wifi,
Zap,
}
+5 -72
View File
@@ -20,82 +20,15 @@ const NON_HTTP_PORTS = new Set([
27017, 27018, // MongoDB
])
function splitFirstHost(host: string): string {
return host.split(',')[0]?.trim() ?? ''
}
function parsePort(port: string): number | undefined {
if (!/^\d+$/.test(port)) return undefined
const parsed = Number.parseInt(port, 10)
return parsed >= 1 && parsed <= 65535 ? parsed : undefined
}
function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null {
const firstHost = splitFirstHost(host)
if (!firstHost) return null
if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) {
const url = new URL(firstHost)
return {
protocol: url.protocol === 'https:' ? 'https' : 'http',
hostname: url.hostname,
port: parsePort(url.port),
}
}
if (firstHost.startsWith('[')) {
const bracketIndex = firstHost.indexOf(']')
if (bracketIndex === -1) return { hostname: firstHost }
const hostname = firstHost.slice(1, bracketIndex)
const remainder = firstHost.slice(bracketIndex + 1)
return {
hostname,
port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined,
}
}
const colonCount = (firstHost.match(/:/g) ?? []).length
if (colonCount === 1) {
const [hostname, rawPort] = firstHost.split(':')
const parsedPort = parsePort(rawPort)
if (hostname && parsedPort != null) {
return { hostname, port: parsedPort }
}
}
return { hostname: firstHost }
}
function normalizePath(path?: string): string {
const trimmed = path?.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
function formatHostname(hostname: string): string {
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
}
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
if (!host) return null
if (svc.port === 22) return null // SSH — no browser
if (svc.protocol === 'udp') return null // UDP — not HTTP
const parts = parseHostParts(host)
if (!parts?.hostname) return null
const effectivePort = svc.port ?? parts.port
if (effectivePort === 22) return null // SSH — no browser
if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null
if (NON_HTTP_PORTS.has(svc.port)) return null
const name = svc.service_name.toLowerCase()
const protocol = parts.protocol ?? (
const isHttps =
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
effectivePort === 443 || effectivePort === 8443
? 'https'
: 'http'
)
const base = `${protocol}://${formatHostname(parts.hostname)}`
const port = effectivePort != null ? `:${effectivePort}` : ''
return `${base}${port}${normalizePath(svc.path)}`
svc.port === 443 || svc.port === 8443
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}`
}
+95 -95
View File
@@ -42,25 +42,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Dark futuristic — the original Homelable look',
colors: {
nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
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' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
@@ -98,25 +98,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Pure black with maximum contrast',
colors: {
nodeAccents: {
isp: { border: '#22d3ee', icon: '#22d3ee' },
router: { border: '#22d3ee', icon: '#22d3ee' },
switch: { border: '#4ade80', icon: '#4ade80' },
server: { border: '#c084fc', icon: '#c084fc' },
proxmox: { border: '#fb923c', icon: '#fb923c' },
vm: { border: '#c084fc', icon: '#c084fc' },
lxc: { border: '#22d3ee', icon: '#22d3ee' },
nas: { border: '#4ade80', icon: '#4ade80' },
iot: { border: '#fbbf24', icon: '#fbbf24' },
ap: { border: '#22d3ee', icon: '#22d3ee' },
camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
isp: { border: '#22d3ee', icon: '#22d3ee' },
router: { border: '#22d3ee', icon: '#22d3ee' },
switch: { border: '#4ade80', icon: '#4ade80' },
server: { border: '#c084fc', icon: '#c084fc' },
proxmox: { border: '#fb923c', icon: '#fb923c' },
vm: { border: '#c084fc', icon: '#c084fc' },
lxc: { border: '#22d3ee', icon: '#22d3ee' },
nas: { border: '#4ade80', icon: '#4ade80' },
iot: { border: '#fbbf24', icon: '#fbbf24' },
ap: { border: '#22d3ee', icon: '#22d3ee' },
camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
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' },
},
nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111',
@@ -154,25 +154,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Clean light theme with dark text',
colors: {
nodeAccents: {
isp: { border: '#0284c7', icon: '#0284c7' },
router: { border: '#0284c7', icon: '#0284c7' },
switch: { border: '#16a34a', icon: '#16a34a' },
server: { border: '#7c3aed', icon: '#7c3aed' },
proxmox: { border: '#ea580c', icon: '#ea580c' },
vm: { border: '#7c3aed', icon: '#7c3aed' },
lxc: { border: '#0284c7', icon: '#0284c7' },
nas: { border: '#16a34a', icon: '#16a34a' },
iot: { border: '#b45309', icon: '#b45309' },
ap: { border: '#0284c7', icon: '#0284c7' },
camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0369a1', icon: '#0369a1' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
isp: { border: '#0284c7', icon: '#0284c7' },
router: { border: '#0284c7', icon: '#0284c7' },
switch: { border: '#16a34a', icon: '#16a34a' },
server: { border: '#7c3aed', icon: '#7c3aed' },
proxmox: { border: '#ea580c', icon: '#ea580c' },
vm: { border: '#7c3aed', icon: '#7c3aed' },
lxc: { border: '#0284c7', icon: '#0284c7' },
nas: { border: '#16a34a', icon: '#16a34a' },
iot: { border: '#b45309', icon: '#b45309' },
ap: { border: '#0284c7', icon: '#0284c7' },
camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
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' },
},
nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff',
@@ -210,25 +210,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Cyberpunk vibes with vivid glowing accents',
colors: {
nodeAccents: {
isp: { border: '#00ffff', icon: '#00ffff' },
router: { border: '#00ffff', icon: '#00ffff' },
switch: { border: '#00ff80', icon: '#00ff80' },
server: { border: '#ff00ff', icon: '#ff00ff' },
proxmox: { border: '#ff8800', icon: '#ff8800' },
vm: { border: '#ff00ff', icon: '#ff00ff' },
lxc: { border: '#00ffff', icon: '#00ffff' },
nas: { border: '#00ff80', icon: '#00ff80' },
iot: { border: '#ffff00', icon: '#ffff00' },
ap: { border: '#00ffff', icon: '#00ffff' },
camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker_host: { border: '#00aaff', icon: '#00aaff' },
docker_container: { border: '#00ddff', icon: '#00ddff' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
isp: { border: '#00ffff', icon: '#00ffff' },
router: { border: '#00ffff', icon: '#00ffff' },
switch: { border: '#00ff80', icon: '#00ff80' },
server: { border: '#ff00ff', icon: '#ff00ff' },
proxmox: { border: '#ff8800', icon: '#ff8800' },
vm: { border: '#ff00ff', icon: '#ff00ff' },
lxc: { border: '#00ffff', icon: '#00ffff' },
nas: { border: '#00ff80', icon: '#00ff80' },
iot: { border: '#ffff00', icon: '#ffff00' },
ap: { border: '#00ffff', icon: '#00ffff' },
camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
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' },
},
nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a',
@@ -266,25 +266,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Everything in terminal green',
colors: {
nodeAccents: {
isp: { border: '#00ff41', icon: '#00ff41' },
router: { border: '#00ff41', icon: '#00ff41' },
switch: { border: '#00cc33', icon: '#00cc33' },
server: { border: '#008822', icon: '#008822' },
proxmox: { border: '#33ff66', icon: '#33ff66' },
vm: { border: '#008822', icon: '#008822' },
lxc: { border: '#00ff41', icon: '#00ff41' },
nas: { border: '#00cc33', icon: '#00cc33' },
iot: { border: '#66ff33', icon: '#66ff33' },
ap: { border: '#00ff41', icon: '#00ff41' },
camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker_host: { border: '#00cc88', icon: '#00cc88' },
docker_container: { border: '#00aacc', icon: '#00aacc' },
generic: { border: '#006600', icon: '#006600' },
groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
isp: { border: '#00ff41', icon: '#00ff41' },
router: { border: '#00ff41', icon: '#00ff41' },
switch: { border: '#00cc33', icon: '#00cc33' },
server: { border: '#008822', icon: '#008822' },
proxmox: { border: '#33ff66', icon: '#33ff66' },
vm: { border: '#008822', icon: '#008822' },
lxc: { border: '#00ff41', icon: '#00ff41' },
nas: { border: '#00cc33', icon: '#00cc33' },
iot: { border: '#66ff33', icon: '#66ff33' },
ap: { border: '#00ff41', icon: '#00ff41' },
camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
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' },
},
nodeCardBackground: '#001100',
nodeIconBackground: '#002200',
+2 -4
View File
@@ -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: {