Compare commits

...

142 Commits

Author SHA1 Message Date
Pouzor b2c668cd89 fix(detail-panel): replace pixel math and fixed tooltip in ServiceBadge
- Remove magic-number width calculations (maxTotalWidth, nameCharWidth)
- Remove useState tooltip + position:fixed custom tooltip div
- Use CSS flex/truncate for layout and Shadcn Tooltip for path hover
- Add ServiceBadge rendering tests (name, port, path, link vs plain text)
2026-04-23 00:31:19 +02:00
findthelorax cb4bf51b5a aligned services and path styling 2026-04-23 00:31:19 +02:00
findthelorax c1dad1a5ae reduces clickable space to the external link icon and the service name and added a tooltip to show truncated paths at the pointer 2026-04-23 00:31:19 +02:00
findthelorax 4ee68b839c prioritize service name and port, truncate path or hide when necessary 2026-04-23 00:31:19 +02:00
findthelorax 0b7691c083 prioritize service name, truncate path or hide when necessary 2026-04-23 00:31:19 +02:00
findthelorax 9979ae9dc8 path max width set to 80px 2026-04-23 00:31:19 +02:00
findthelorax 21b19a775e restore: tooltip.tsx 2026-04-23 00:31:19 +02:00
findthelorax 34182f0d11 aligned services and path styling 2026-04-23 00:31:19 +02:00
Pouzor ebc1d41d5c feat: add docker_container node type and fix container mode for non-proxmox types
- Add docker_container node type (Package icon, sky-blue accent) as child of docker_host
- Parent selector for docker_container filters to docker_host only via nodeType field
- Virtual edge drag-connect syncs parent_id for docker_container <-> docker_host
- Fix setProxmoxContainerMode: remove proxmox-only guard so width/height are properly
  set/cleared for all container-capable types (docker_host, vm, lxc)
- Fix handleAddNode: only give group size when container_mode=true, making create
  and reload behavior consistent (was giving size unconditionally for CONTAINER_MODE_TYPES)
- Add regression tests for docker_host container mode toggle and docker_container nesting
2026-04-22 22:41:01 +02:00
findthelorax fe5e3c9858 revert: restore workflow to upstream version 2026-04-22 22:41:01 +02:00
findthelorax 78b47384da fixed the container mode toggle styling for backward compatability and proper padding to remain centered in it's parent element 2026-04-22 22:41:01 +02:00
findthelorax 38e6604f50 align canvasStore expectations with container-mode behavior 2026-04-22 22:41:01 +02:00
findthelorax 0e70b45e8a code aligned cleanup 2026-04-22 22:41:01 +02:00
findthelorax 7312132767 adjusted test to be more exhaustive of all container and parent node types 2026-04-22 22:41:01 +02:00
findthelorax cb5b1bd2e2 adjusting wording by replacing proxmox with generics since multiple node types can be a parent node 2026-04-22 22:41:01 +02:00
findthelorax c9a402fa0a fix: shows the proper parent node names once selected in the edit node modal 2026-04-22 22:41:01 +02:00
findthelorax 9f880395da fix: adjusted deserialize for all virtualization node types 2026-04-22 22:41:01 +02:00
findthelorax 585df726e7 feature: added a Container Mode toggle to the Virtualization group of node types 2026-04-22 22:41:01 +02:00
Brett Ferrante 1a3cde3a02 Update Docker image references to use repository owner 2026-04-22 22:41:01 +02:00
findthelorax a8ca4f1cca revert: restore workflow to upstream version 2026-04-21 16:08:40 +02:00
findthelorax feb05a1df5 updated node modal with consistent casing for type and check method following standard conventions 2026-04-21 16:08:40 +02:00
Brett Ferrante 0204a7ddeb Update Docker image references to use repository owner 2026-04-21 16:08:40 +02:00
Remy 03e1e32af1 Create LICENSE 2026-04-21 12:29:18 +02:00
Pouzor c9cd6a08fc add CONTRIBUTING.md 2026-04-21 12:23:50 +02:00
Pouzor daa78a036a bump: version 1.10.2 2026-04-21 11:52:43 +02:00
Pouzor 3deb750441 fix: NaN guard on settings interval input and validate release URL scheme 2026-04-21 11:43:15 +02:00
Pouzor 88554ef952 fix: stop click propagation on pending device checkbox to prevent modal opening 2026-04-21 11:37:43 +02:00
Pouzor 110592f89e fix: checkbox onChange anti-pattern in PendingDevicesPanel 2026-04-21 11:28:55 +02:00
Pouzor 8b8da5584c feat: add logout button to sidebar 2026-04-21 11:23:52 +02:00
Pouzor 4260a6582c Bimp version 1.10.1 2026-04-20 15:05:12 +02:00
Pouzor a0f18dd237 fix: use custom icon in proxmox container mode header 2026-04-20 14:57:45 +02:00
Pouzor 00edc32aeb fix: show visible properties on proxmox container mode node header 2026-04-20 14:45:37 +02:00
findthelorax cd6a788f77 fix: test was missing leading / 2026-04-20 14:37:08 +02:00
findthelorax 9cf6a48b04 fix: port input to text numeric and removed up/down arrows 2026-04-20 14:37:08 +02:00
findthelorax 2c94616afa cleanup path examples 2026-04-20 14:37:08 +02:00
findthelorax c7be851c34 feature: add support for services to use a path 2026-04-20 14:37:08 +02:00
Remy fddfd0a769 Merge pull request #90 from Pouzor/feat/zone-color-opacity
feat: add opacity slider to zone color pickers (fixes #72)
2026-04-20 14:13:35 +02:00
Pouzor 074b49358b feat: add opacity slider to zone color pickers (fixes #72)
The native <input type="color"> only supports 6-digit hex, stripping alpha
and forcing background/border/text colors to be fully opaque on edit.

Each color field now shows an opacity slider (0–100%) below the swatch.
Values are stored as 8-digit hex (#rrggbbaa). Existing zones with 6-digit
colors are handled transparently (alpha defaults to 100%).

- colorUtils.ts: hexToRgba / rgbaToHex8 helpers
- GroupRectModal: opacity sliders for all three color fields
- 26 new tests across colorUtils and GroupRectModal
2026-04-20 14:05:06 +02:00
Remy a47b7649f0 Merge pull request #89 from Pouzor/feat/export-quality
feat: PNG export quality selector (standard / high / ultra)
2026-04-20 13:50:42 +02:00
Pouzor 7e08a85f73 feat: add quality selector to PNG export (standard / high / ultra)
Clicking Export PNG now opens a modal with three quality presets:
- Standard (1× pixel ratio) — small file
- High (2×, default) — recommended for sharing
- Ultra (4×) — print quality

Adds ExportModal component, updates exportToPng() to accept a quality
param, and wires the modal into App.tsx replacing the direct export call.
2026-04-20 11:40:16 +02:00
Remy c7c5183356 Merge pull request #87 from findthelorax/fix/reset-form-data
fix: resets form data after submission
2026-04-20 10:48:02 +02:00
Pouzor 7608d07255 fix: flush before reading node IDs in bulk/single approve; 404/409 guards; catch scan errors
- db.flush() ensures node.id is populated before reading — fixes bulk approve
  where node_ids were null, causing frontend to skip addNode for every device
- approve_device raises 404 on missing device, 409 on already-processed device
- _background_scan rollbacks dirty session then marks run as "failed"
- Explicit Node() field mapping instead of **model_dump() to prevent injection
- update_scan_config rolls back in-memory change if save_overrides() fails
- clear_pending uses bulk DELETE instead of N individual row deletes
2026-04-20 10:41:02 +02:00
Remy 1bc6798d76 Merge pull request #84 from findthelorax/feature/property-icons
feature: added new icons for properties
2026-04-20 10:07:49 +02:00
findthelorax f6de7d1770 fix: removed setState within an effect, responsibility moved to parent key 2026-04-20 00:13:07 -04:00
findthelorax 9dddd00858 fix: resets form data after submission 2026-04-20 00:06:29 -04:00
findthelorax a5bf9c9db6 feature: added new icons for properties 2026-04-19 21:00:20 -04:00
Remy a9c5c538b4 Merge pull request #82 from Pouzor/1.10.0
1.10.0
2026-04-19 23:49:38 +02:00
Pouzor a816faa0b9 fix: prevent node width expansion when content overflows after resize
Proxmox nodes with container_mode=false fell through both width conditions
in deserializeApiNode and got no explicit width on reload, causing RF to
auto-size to content width and ignoring the user's manual resize.

- canvasSerializer: unified width restore logic — saved width applies to all
  node types; proxmox container_mode defaults (300x200) only kick in when
  no saved width exists
- BaseNode: add overflow-hidden + min-w-0 to properties row so truncate
  actually clips long values instead of expanding the node
2026-04-19 23:43:15 +02:00
Pouzor fbfacec6dc fix: prevent node from expanding beyond resized width on reload 2026-04-19 22:58:10 +02:00
Pouzor b5eb8d1b74 fix: remove duplicate primaryIp export in maskIp.ts after rebase 2026-04-19 22:30:50 +02:00
Pouzor 0193f933ce feat: bulk approve/hide pending devices (#70)
- Backend: POST /scan/pending/bulk-approve and /scan/pending/bulk-hide endpoints (registered before dynamic routes to avoid conflict); bulk-approve response includes device_ids for frontend mapping
- Frontend: PendingDevicesPanel gains per-row checkboxes, select-all, and a bulk action bar (Approve N / Hide N) that appears when ≥1 device is selected
- Tests: 6 new backend API tests + 7 new frontend UI tests for bulk selection flows
2026-04-19 22:13:40 +02:00
Pouzor 5ad5eba58c feat: add connection handles to zone nodes (closes #58)
- GroupRectNode now renders source+target handles on all four sides
  (top, right, bottom, left) using IDs zone-{side} / zone-{side}-t
- Handles are hover-only: opacity 0 by default, fade in on mouse enter
- Handle color matches the zone border color (respects custom_colors)
- Zone↔zone and zone↔node connections both allowed; edge type picker
  (EdgeModal) opens on connect so user chooses ethernet/wifi/vlan/etc.
- Add GroupRectNode.test.tsx: verifies 8 handles rendered (4 source + 4 target)
- Fix @xyflow/react mocks in LiveView and CanvasContainer tests to include Position
2026-04-19 22:13:40 +02:00
Pouzor ef96cafcc8 feat: IPv6 support and multi-IP per node (closes #60)
- maskIp handles IPv6 addresses (masks second and last group)
- maskIp handles comma-separated IP strings (masks each address)
- Add splitIps() helper to parse comma-separated IP field
- Add primaryIp() helper used by status checker (first IP wins)
- BaseNode renders each IP on its own line when comma-separated
- NodeModal placeholder shows comma-separated example
- Backend status_checker uses only first IP for connectivity checks
- Expand maskIp test suite: IPv6, comma-separated, splitIps, primaryIp
2026-04-19 22:13:40 +02:00
Pouzor 6c9974b357 bump version 1.10 2026-04-19 22:13:40 +02:00
Pouzor ce5fc785e1 chore: bump version to 1.10.0 2026-04-19 22:13:40 +02:00
Pouzor 0019c086cf feat: automatic DB backup before migrations using VERSION file
- Add VERSION file at repo root as single source of truth for app version
- frontend/vite.config.ts reads VERSION file instead of package.json
- backend config.py exposes APP_VERSION read from VERSION (dev) or /app/VERSION (Docker)
- database.py backs up DB to homelab.db.back-{version} before running migrations
  (skipped if DB doesn't exist or backup already exists — fully idempotent)
- Dockerfile.backend and Dockerfile.frontend copy VERSION into the image
- Add test_db_backup.py with 4 tests covering create/skip/idempotent/version cases
2026-04-19 22:09:43 +02:00
Remy 0eff7da46e Merge pull request #81 from Pouzor/fix/clickable-ip-multiip
fix: handle multi-IP for clickable IP link
2026-04-19 22:08:19 +02:00
Pouzor 2e6ee9dad2 fix: handle multi-IP and add tests for clickable IP link
Follow-up to #78:
- Use primaryIp() so href targets the first IP when data.ip is comma-separated (e.g. "192.168.1.1, 2001:db8::1")
- Add primaryIp() helper to maskIp.ts
- Add 4 tests covering single IP link, absent IP, multi-IP href, multi-IP display text
2026-04-19 22:04:37 +02:00
findthelorax 81b109f981 feat: make IP Address clickable in detail panel
- Display IP as a clickable link that opens http://<ip> in a new tab
- Match the existing Hostname link styling and behavior
- Add external link icon to indicate it's clickable
2026-04-19 10:08:09 -04:00
Brett Ferrante 73b16a7620 Merge pull request #15 from Pouzor/main
Merge updates from Pouzor main
2026-04-19 09:21:21 -04:00
Remy a37bf101d2 Merge pull request #76 from findthelorax/bug/drag-from-title
Fix/drag from title
2026-04-19 11:41:41 +02:00
Remy 5def6b7fbf Merge pull request #75 from dopp1e/fix-curl-healthcheck
fix: add curl to backend image to support the default healthcheck
2026-04-19 11:35:07 +02:00
Brett Ferrante eb235cb101 Merge branch 'Pouzor:main' into bug/drag-from-title 2026-04-18 22:14:00 -04:00
Brett Ferrante 04a1c63558 Merge pull request #14 from Pouzor/main
Merge with Pouzor main branch
2026-04-18 21:36:53 -04:00
doppie 88f0c03c57 fix: add curl to backend image to support the default healthcheck 2026-04-19 01:08:51 +02:00
Remy 718aff5918 Merge pull request #73 from Pouzor/feature/extended-zoom
feat: extended zoom range + zoom-aware node borders
2026-04-18 23:10:24 +02:00
Pouzor 70311e6331 fix: propertyIcons mock, key stability, isValidConnection memoized
- Add resolvePropertyIcon mock to BaseNode tests
- Reset mockZoom in top-level beforeEach to prevent test bleed
- Use prop.key instead of index as list key in BaseNode properties
- Extract isValidConnection into useCallback in CanvasContainer
2026-04-18 23:03:21 +02:00
Pouzor 6a3da5aded fix: memoize onBeforeDelete and fix test mock selectors
Extract onBeforeDelete into useCallback to avoid new fn ref each render.
Fix useThemeStore/useCanvasStore mocks to call the selector fn rather
than ignoring it, and reset mockZoom in beforeEach.
2026-04-18 23:03:21 +02:00
Pouzor 35c3d00f17 fix: memoize borderWidth and sync boxShadow glow ring with zoom
- Wrap borderWidth in useMemo to avoid re-computing on unrelated renders
- Replace hardcoded 1px in boxShadow with borderWidth so glow ring
  matches border thickness at all zoom levels
- Add useViewport mock + 4 zoom-scaling tests to BaseNode.test.tsx
2026-04-18 23:01:27 +02:00
findthelorax 3a5cb0de21 feature: extend zoom out and border width math 2026-04-18 23:01:27 +02:00
Remy f72d44d5e5 Merge pull request #71 from Pouzor/feature/double-click-to-edit
feat: double-click node to open edit modal
2026-04-18 21:55:01 +02:00
Pouzor a7b244502e fix: bump pytest-asyncio to 1.3.0 (first version supporting pytest 9) 2026-04-18 21:52:32 +02:00
Pouzor 72d5a51b44 fix: bump pytest-asyncio to 1.0.0 for pytest 9 compatibility
pytest-asyncio 0.26.0 requires pytest<9; 1.0.0 supports pytest 9.x
2026-04-18 21:45:10 +02:00
Pouzor 12f46715c1 fix: bump python-multipart, pytest, pytest-asyncio to fix CVEs
- python-multipart 0.0.22 → 0.0.26 (CVE-2026-40347)
- pytest 8.3.3 → 9.0.3 (CVE-2025-71176)
- pytest-asyncio 0.24.0 → 0.26.0 (pytest 9 compat)
2026-04-18 21:36:09 +02:00
Pouzor 62f674b15d fix: extract handleNodeDoubleClick into useCallback and add tests
Replace inline arrow with named useCallback handler to avoid creating
a new fn ref on every render. Add two CanvasContainer tests covering
the double-click callback and the no-op path when prop is omitted.
2026-04-18 21:09:49 +02:00
findthelorax b0a67744f5 bug: fixed to allow draging from the titlebar 2026-04-17 22:34:17 -04:00
findthelorax 04069e080a feature: double-click on a node to open an edit modal 2026-04-16 10:10:30 -04:00
Pouzor dd1f690892 feat: add logo assets in docs/logo
Icon (house + network nodes, dark bg) and horizontal logo (icon +
"Home"/"lable" color-split text) in multiple sizes:
- icon.svg + icon-16/32/64/128/256/512.svg
- logo.svg + logo-sm/md/lg.svg
2026-04-10 12:04:02 +02:00
Remy 8b04deb608 Merge pull request #56 from Pouzor/1.9
v1.9.0 — Node properties, edge waypoints, version display
2026-04-09 19:17:14 +02:00
Pouzor 531fb12eab fix: replace SVG animate with CSS animation for basic edge to prevent bounce
SVG <animate> restarts on every React re-render (especially visible under
StrictMode double-invoke in dev), causing a visible bounce. CSS animation
runs on the compositor thread independently of React renders.
2026-04-09 16:52:43 +02:00
Pouzor e666abefad chore: remove db backup from tracking and add *.db.back to gitignore 2026-04-09 16:34:25 +02:00
Pouzor f1e9fd7cf8 test: verify standalone mode round-trips for properties, waypoints and animations
- Fix ApiEdge type: add 'basic' to animated union
- Add 5 standalone storage tests: properties, empty properties, waypoints,
  basic animation, and all three animation types round-trip through localStorage
2026-04-09 16:28:20 +02:00
Pouzor 9134812e32 feat: add Basic edge animation with consistent downward direction
Adds a "basic" animation type that uses React Flow's native moving-dash
style — the entire stroke is animated dashes with no solid line underneath.
Direction is always physically downward (top node → bottom node) regardless
of which end was the source when the edge was drawn.
2026-04-09 16:04:43 +02:00
Pouzor 4844576c3b feat: add Basic animation type for edges
Adds a new "basic" animation mode that uses React Flow's native animated
dashes — the entire stroke moves as dashes, with no solid line underneath.
Distinct from "snake" (moving dot) and "flow" (overlay on solid line).

- Frontend: new Basic button in EdgeModal, animMode mapping, passes
  animated={true} to BaseEdge when mode is "basic"
- Backend: normalize_animated accepts "basic" as a valid value
- Tests: EdgeModal + canvas round-trip tests for basic mode
2026-04-09 15:46:52 +02:00
Pouzor 4976f2e694 test: improve canvas route coverage to 100% and fix async tracing
- Add 22 new tests for canvas save/load: properties round-trip,
  edge waypoints, edge handles, animated, parent/child nodes,
  group nodes, all node fields, bottom_handles, services/notes,
  check_method/target, viewport update branches, edge update branch
- Fix coverage.py async tracing by setting core = "sysmon" in
  pyproject.toml (Python 3.12+ sys.monitoring API), resolving the
  sys.settrace drop-after-await issue
2026-04-09 15:17:58 +02:00
Pouzor d6a7b062f4 fix: clear node height when properties change to prevent overflow outside border 2026-04-09 14:34:01 +02:00
Pouzor 94c6ac7fa7 fix: add type parameters to bare list annotations for mypy compliance 2026-04-09 14:23:13 +02:00
Pouzor e0f96001e2 chore: bump version to 1.9.0 2026-04-09 14:06:24 +02:00
Pouzor 3d89ba3b6f feat: replace static hardware fields with dynamic node properties
Replaces the 4 fixed hardware columns (cpu_count, cpu_model, ram_gb,
disk_gb) with a flexible properties system. Each property has a key,
value, icon (from a curated Lucide picker), and a visibility toggle
that controls whether it appears on the canvas node card.

- Backend: add `properties` JSON column to Node model; data migration
  converts existing hardware rows to properties with correct icons
  (idempotent, old columns kept for safety)
- Backend: add `properties` to NodeBase, NodeUpdate, NodeSave schemas
  and canvasSerializer so values survive canvas save/load
- Frontend: add NodeProperty type; new propertyIcons.ts registry (20
  icons); BaseNode renders visible properties with legacy hardware
  fallback for unmigrated nodes
- Frontend: DetailPanel gains interactive properties section (add /
  edit / remove / toggle visibility / icon picker) replacing the
  read-only hardware block; hardware section removed from NodeModal
- Tests: 6 migration tests, 7 API tests, 8 DetailPanel property tests,
  6 BaseNode render/fallback tests, 9 propertyIcons util tests
2026-04-09 13:47:36 +02:00
Pouzor 3afc8ed3d8 fix: persist edge waypoints in backend
Add waypoints JSON column to edges table, include it in all edge
schemas (EdgeBase, EdgeUpdate, canvas CanvasEdge) and add the
idempotent ALTER TABLE migration so existing databases are upgraded
on next startup.
2026-04-08 23:31:53 +02:00
Pouzor 9e8bab5dec feat: add interactive edge waypoints with smooth path editing
- Drag waypoints to reshape edges; double-click a waypoint to remove it
- + handles at segment midpoints to insert new waypoints
- Bezier style: catmull-rom smooth curves through waypoints
- Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px)
  - First + handle biased to source axis for perpendicular node exit
  - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously
- Clear path button in EdgeModal when waypoints exist
- Waypoints serialised/deserialised with canvas state
2026-04-08 22:42:18 +02:00
Pouzor 9d9fdd61e9 Merge branch 'feat/version-display' into 1.9 2026-04-08 16:19:52 +02:00
Pouzor b0df8f389a fix: align dot grid to snap grid and fix node selection layout shift 2026-04-08 13:58:27 +02:00
Pouzor 75c7f25a30 feat: display app version in sidebar with GitHub release check 2026-04-08 12:22:39 +02:00
Pouzor 8bd1c48976 chore: bump version to 1.8.3 2026-04-07 01:04:10 +02:00
Remy 05c98355a6 Update INSTALLATION.md 2026-04-07 00:51:25 +02:00
Pouzor 323dea6798 Remove custom proxmox script and doc 2026-04-07 00:49:52 +02:00
Pouzor 19cb4b71f5 chore: upgrade lucide-react to v1.7.0 2026-04-07 00:06:21 +02:00
Pouzor fd86c0f6ad chore: update frontend npm dependencies (patch/minor) 2026-04-07 00:02:33 +02:00
Pouzor 00d44abfad feat: reduce snap grid from 16px to 8px for finer node positioning 2026-04-06 23:54:56 +02:00
Pouzor 3bd18ab543 chore: bump version to 1.8.2 2026-04-05 15:01:57 +02:00
Pouzor 4d8bb246f1 fix: scan no longer triggers before user confirms in ScanConfigModal 2026-04-05 14:35:10 +02:00
Pouzor 07da498d18 fix: use correct ping args on Windows (-n/-w instead of -c/-W) 2026-04-05 14:08:36 +02:00
Pouzor 0e59f15608 chore: bump version to 1.8.1 2026-04-05 10:32:37 +02:00
Pouzor bd22891fab fix: correct UTC timestamp display and pre-existing code quality issues 2026-04-05 03:06:29 +02:00
Pouzor d96b502524 fix: correct UTC timestamp display in scan history, pending devices, and detail panel 2026-04-05 02:50:23 +02:00
Pouzor 9cb9d02459 feat: extend search (Ctrl+F and Ctrl+K) to include pending devices
Both SearchBar and SearchModal now fetch and search pending devices by
IP, hostname, and service name. Selecting a pending result opens the
sidebar to the Pending tab and highlights the matching device.
2026-04-05 02:40:12 +02:00
Pouzor 08a3a689c5 chore: bump version to 1.8.0 2026-04-04 23:29:20 +02:00
Remy 034ba599f4 Merge pull request #40 from Pouzor/feat/bottom-handles
feat: configurable bottom handles, scanner rewrite, UI polish
2026-04-04 23:23:20 +02:00
Pouzor 38c5bcb606 feat: configurable bottom handles, scanner rewrite, UI polish
## New features
- Configurable bottom connection points per node (1–4 handles)
- Fit view on load
- LiveView improvements
- Node modal: inline Type/Icon picker, default icon in trigger
- Remove redundant Save button from ScanConfigModal

## Scanner fixes
- Phase 1: replace nmap ARP sweep with concurrent asyncio ping sweep
  (50 parallel pings, 1s timeout). Zero false positives, works in any
  Docker network mode. Supplements with /proc/net/arp for ICMP-blocked devices.
- Phase 2: explicit -sS (root) / -sT (non-root) scan type; bump
  host-timeout to 60s; gather(return_exceptions=True) so one failing
  host doesn't abort the batch
- Fix 404 on missing device in hide/ignore
- Validate CIDR ranges to prevent nmap injection
- Thread-safe cancel set, pre-fetch canvas/hidden IPs (no N+1 queries)
- Logging: attach StreamHandler to root logger so app.* logs are visible

## Tests
- 21 backend scanner tests (ping sweep, ARP cache, Phase 2 tolerance)
- Full NodeModal coverage (53 tests)
- LiveView, store, edge label tests
2026-04-04 23:15:47 +02:00
Pouzor 0a72f9be67 test: add tests for useStatusPolling hook and Sidebar component
- 12 tests for useStatusPolling: WebSocket lifecycle, auth guards,
  message routing (status/scan_device_found), error resilience, cleanup
- 21 tests for Sidebar: render, collapse/expand, node stats, action
  callbacks, view navigation, scan trigger, settings panel toggle
2026-04-03 22:55:56 +02:00
Pouzor 041583c9b4 fix: set correct edge handles on YAML import
Edges created by parseYamlToCanvas had no sourceHandle/targetHandle,
causing React Flow to connect everything at the top of nodes.

- Regular links: bottom → top-t
- Parent edges: bottom → top-t
- clusterR/clusterL: cluster-right → cluster-left
2026-04-03 00:50:34 +02:00
Pouzor 5a3e8ea0b1 chore: bump version to 1.7.1 2026-04-03 00:12:22 +02:00
Pouzor 70907e37bc fix: LXC/VM parent-child UX bugs with Proxmox container mode
- Don't create virtual edge when parent Proxmox is in container mode
  (containment is shown visually — edge was redundant and confusing)
- updateNode now syncs React Flow parentId/extent/position when
  data.parent_id changes, so nesting/un-nesting is immediate without
  requiring a save+reload
- Attaching to container: converts position to parent-relative coords
- Detaching from container: converts position back to absolute coords
  so the node escapes the container box immediately
- Ensure parent nodes precede children in array on attachment
  (React Flow rendering requirement)
2026-04-02 22:30:54 +02:00
Remy e356d433cb Merge pull request #37 from Pouzor/feat/iot-discovery
feat: improve IoT device detection via two-phase scan and mDNS discovery
2026-04-02 17:56:34 +02:00
Pouzor cf7777e0af feat: improve IoT device detection via two-phase scan and mDNS discovery
- Two-phase nmap: ARP sweep first to find all alive hosts (incl. IoT with
  no open TCP ports), then port scan only alive hosts
- mDNS/Bonjour discovery via zeroconf for Shelly, ESPHome, HomeKit devices
- Add CoAP ports (5683, 5684, 4915) to port scan and IoT type hints
- Expand MAC OUI table with Shelly, Espressif, Sonoff, Tapo, Hue, IKEA, Tuya
- IoT vendor MAC takes precedence over generic HTTP port type hints
- Reorder suggest_node_type priority: iot now beats server
2026-04-02 17:41:51 +02:00
Pouzor 255443b8e1 fix: harden scheduler startup and interval validation
- Wrap shutdown() in try/except to prevent double-scheduler if teardown fails
- Guard reschedule_status_checks with interval_seconds >= 10 validation
2026-04-02 17:39:52 +02:00
Pouzor 7e24878077 fix: pass scalars to _check_single_node to prevent DetachedInstanceError
- Refactor _check_single_node to accept plain scalar args (node_id,
  check_method, check_target, ip) instead of a detached ORM Node object
- Extract scalars inside the session in _run_status_checks before it closes
- Capture datetime.now() once per check to keep DB and WebSocket timestamps consistent
2026-04-02 17:32:42 +02:00
Pouzor ff1bc7340d fix: resolve mypy errors in scheduler (dict type params + str | None arg) 2026-04-02 17:24:26 +02:00
Pouzor a7c9abbb9a npm audit fix 2026-04-02 11:48:37 +02:00
Pouzor e4bfab7e58 perf: run status checks concurrently to prevent scheduler overrun
Replace sequential node checks with asyncio.gather so all nodes are
checked in parallel. Add coalesce=True + max_instances=1 to suppress
APScheduler "maximum instances reached" log spam.
2026-04-02 11:41:08 +02:00
Pouzor d5069d9b96 chore: bump version to 1.7.0 2026-04-02 01:22:22 +02:00
Pouzor 7498a10c14 fix: drop -qq on apt-get update and add --fix-missing to handle stale mirror cache in LXC install 2026-04-02 00:24:18 +02:00
Pouzor a8dd41a156 fix: add group NodeType to all theme definitions 2026-04-02 00:20:32 +02:00
Pouzor bdf3b6ea40 feat: add Ctrl+F search bar to canvas
- Ctrl+F / Cmd+F opens floating search bar at top-center of canvas
- Filters nodes by label, IP, hostname and service name (case-insensitive)
- Shows match count and no-results message
- Click result selects node and flies camera to it with animation
- Escape or × closes the bar
- groupRect nodes excluded from results
- Handles grouped nodes with correct absolute position for navigation
2026-04-01 23:32:28 +02:00
Pouzor 0b89244317 feat: lasso selection, multi-select panel, and named groups
- Add lasso/box selection via selectionOnDrag (Space to pan, lasso by default)
- Add lasso/pan toggle button in canvas controls (bottom-left)
- Multi-select panel in right panel when 2+ nodes selected (including zones)
- Create named Group node from selected nodes with bounding box math
- Group node: resizable, inline rename, show/hide border toggle, status summary
- GroupDetailPanel: lists members, online/offline count, ungroup action
- Fix group persistence after save/reload (extend proxmox container map to include group nodes)
- Fix groupRect serialization to preserve parent_id
- Remove background color from group and proxmox container node wrappers
- Add tests for all new store actions, GroupNode, MultiSelectPanel, GroupDetailPanel
2026-04-01 23:15:52 +02:00
Pouzor 45a17b0254 fix: use node:20-slim in build stage to fix lightningcss musl binary error 2026-04-01 14:22:45 +02:00
Pouzor 057891f7d5 feat: add stop scan button in UI with backend cancellation support
- POST /scan/{run_id}/stop endpoint signals running scan to cancel
- Scanner checks cancellation flag between CIDR ranges and hosts, exits early
- Cancelled scans get status 'cancelled' instead of 'done'
- Stop button (red StopCircle) shown in Scan History panel for running scans
- 6 new backend tests, 5 new frontend tests
2026-04-01 14:18:44 +02:00
Remy 5321070720 Update INSTALLATION.md 2026-03-31 14:17:14 +02:00
Pouzor 59e5a95912 docs: clarify bcrypt dollar-sign escaping for .env vs docker-compose.yml
Fixes #31
2026-03-31 14:13:22 +02:00
Pouzor 985ced6bf5 chore: bump version to 1.6.0 2026-03-31 00:33:28 +02:00
Remy 05a647aac7 Merge pull request #30 from Pouzor/fix/delete-key-and-undo
fix: DEL key deletes nodes and deletion is undoable
2026-03-31 00:29:16 +02:00
Pouzor 1444a81150 fix: DEL key deletes nodes and deletion is now undoable
- Add 'Delete' to deleteKeyCode so both Backspace and Delete remove nodes
- Call snapshotHistory() in onBeforeDelete (keyboard) and in DetailPanel
  handleDelete (button) so deletions can be undone with Ctrl+Z
2026-03-31 00:19:27 +02:00
Pouzor 1f884fd1db fix: guard scheduler against double-start and unguarded reschedule 2026-03-31 00:03:01 +02:00
Pouzor e9152df17a fix: remove stale reschedule call from scan config after settings endpoint split 2026-03-31 00:01:31 +02:00
Pouzor e7fc091701 fix: reschedule APScheduler job immediately when status check interval is updated
Interval was read once at startup — changing it via API had no effect
until server restart. Now calls reschedule_status_checks() after saving.
2026-03-30 23:59:47 +02:00
Remy 7071f8ef5a Merge pull request #29 from Pouzor/feat/scan-dedup-skip-canvas
feat: scan dedup, skip canvas/hidden nodes, settings endpoint
2026-03-30 23:37:18 +02:00
Pouzor 350dc14a16 test: add SettingsPanel tests covering settingsApi integration
- Opens panel and calls settingsApi.get
- Displays interval loaded from API
- Saves updated interval via settingsApi.save
- Shows error toast on save failure
- Toggles panel closed on second Settings click
2026-03-30 23:14:42 +02:00
Pouzor 68c7672cea feat: split scan config and app settings into separate endpoints
- New GET/POST /api/v1/settings for status check interval
- Scan /api/v1/scan/config now handles ranges only
- Frontend: settingsApi client, SettingsPanel uses settingsApi
- ScanConfigModal no longer reads/writes interval
- 4 new backend tests for settings endpoint
2026-03-30 23:06:51 +02:00
Pouzor 381f870bb5 fix: update ScanConfigModal tests after removing interval field
- Replace interval display test with interval-preservation test
- Reset saveConfig mock call history in beforeEach to prevent test bleed
2026-03-30 22:24:30 +02:00
Pouzor 58381b97d2 feat: move status check interval to sidebar Settings panel
- Add Settings item in sidebar actions section (below Save Canvas)
- Settings panel shows status check interval with save button
- Remove interval field from ScanConfigModal (now belongs in Settings)
- Scan modal shows a hint pointing to sidebar Settings
2026-03-30 22:21:36 +02:00
Pouzor ce4af14ee6 feat: deduplicate pending devices and skip canvas/hidden nodes on scan
- At scan start, purge any pending entries whose IPs already exist in canvas
- Skip canvas nodes (approved) during scan — don't re-add to pending
- Skip hidden devices during scan — respect user's hide decision
- Add 4 tests covering all new behaviors
2026-03-30 22:01:47 +02:00
123 changed files with 11196 additions and 2442 deletions
+1
View File
@@ -45,6 +45,7 @@ htmlcov/
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
*.db.back
# Docker # Docker
.docker/ .docker/
+276
View File
@@ -0,0 +1,276 @@
# 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.
+3 -2
View File
@@ -2,13 +2,14 @@ FROM python:3.13-slim
WORKDIR /app WORKDIR /app
# Install nmap for network scanning + iputils-ping for ping-based status checks # 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 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ . COPY backend/ .
COPY VERSION /app/VERSION
# Create data directory (volume mount point) # Create data directory (volume mount point)
RUN mkdir -p /app/data RUN mkdir -p /app/data
+3 -1
View File
@@ -1,7 +1,8 @@
# Stage 1: build # Stage 1: build
# Use the native build platform so npm ci never runs under QEMU emulation. # Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent. # The build output (static HTML/JS/CSS) is platform-independent.
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder # node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine.
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
ARG VITE_STANDALONE=false ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE ENV VITE_STANDALONE=$VITE_STANDALONE
@@ -11,6 +12,7 @@ COPY frontend/package*.json ./
RUN npm ci RUN npm ci
COPY frontend/ . COPY frontend/ .
COPY VERSION ../VERSION
RUN npm run build RUN npm run build
# Stage 2: serve # Stage 2: serve
+17 -32
View File
@@ -11,9 +11,18 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`. > Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
> >
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"` Generate a new hash:
> ```bash
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret. docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
```
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
```
## Quick Start — Frontend only ## Quick Start — Frontend only
@@ -44,37 +53,13 @@ docker compose up -d
## Proxmox LXC Install ## Proxmox LXC Install
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically: You can now install Homelable with community-scripts (proxmox-VE) :
`https://community-scripts.org/scripts/homelable`
```bash ```bash
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh) bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
```
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
```bash
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
```
The backend runs as a systemd service, the frontend is served via nginx on port 80.
> To install manually inside an existing Debian/Ubuntu machine or LXC:
> ```bash
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
> ```
### Update (LXC)
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
```bash
sudo bash /opt/homelable/scripts/update.sh
```
Or directly from GitHub:
```bash
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
``` ```
--- ---
+21
View File
@@ -0,0 +1,21 @@
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
@@ -0,0 +1 @@
1.10.2
+146 -24
View File
@@ -1,8 +1,10 @@
import ipaddress
import logging import logging
import uuid
from typing import Any from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,12 +14,25 @@ from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.schemas.nodes import NodeCreate from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import run_scan from app.services.scanner import request_cancel, run_scan
class BulkActionRequest(BaseModel):
device_ids: list[str]
class ScanConfig(BaseModel): class ScanConfig(BaseModel):
ranges: list[str] ranges: list[str]
interval_seconds: int
@field_validator("ranges")
@classmethod
def validate_cidr(cls, v: list[str]) -> list[str]:
for r in v:
try:
ipaddress.ip_network(r, strict=False)
except ValueError as exc:
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
return v
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,7 +41,15 @@ router = APIRouter()
async def _background_scan(run_id: str, ranges: list[str]) -> None: async def _background_scan(run_id: str, ranges: list[str]) -> None:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
await run_scan(ranges, db, run_id) try:
await run_scan(ranges, db, run_id)
except Exception:
logger.exception("Scan run %s failed unexpectedly", run_id)
await db.rollback()
run = await db.get(ScanRun, run_id)
if run and run.status == "running":
run.status = "failed"
await db.commit()
@router.post("/trigger", response_model=ScanRunResponse) @router.post("/trigger", response_model=ScanRunResponse)
@@ -44,18 +67,105 @@ async def trigger_scan(
return run return run
@router.post("/{run_id}/stop", response_model=dict)
async def stop_scan(
run_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, bool]:
try:
uuid.UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
run = await db.get(ScanRun, run_id)
if not run:
raise HTTPException(status_code=404, detail="Scan run not found")
if run.status != "running":
raise HTTPException(status_code=409, detail="Scan is not running")
request_cancel(run_id)
return {"stopping": True}
@router.get("/pending", response_model=list[PendingDeviceResponse]) @router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
return list(result.scalars().all()) return list(result.scalars().all())
@router.delete("/pending", response_model=dict)
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"))
await db.commit()
return {"deleted": result.rowcount}
@router.get("/hidden", response_model=list[PendingDeviceResponse]) @router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden")) result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
return list(result.scalars().all()) return list(result.scalars().all())
@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_devices(
payload: BulkActionRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
result = await db.execute(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
PendingDevice.status == "pending",
)
)
devices = result.scalars().all()
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) @router.post("/pending/{device_id}/approve", response_model=dict)
async def approve_device( async def approve_device(
device_id: str, device_id: str,
@@ -64,13 +174,24 @@ async def approve_device(
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if not device:
device.status = "approved" raise HTTPException(status_code=404, detail="Device not found")
node = Node(**node_data.model_dump()) if device.status != "pending":
db.add(node) raise HTTPException(status_code=409, detail="Device already processed")
await db.commit() device.status = "approved"
return {"approved": True, "node_id": node.id} node = Node(
return {"approved": False} label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
status=node_data.status,
services=node_data.services or [],
)
db.add(node)
await db.flush()
node_id = node.id
await db.commit()
return {"approved": True, "node_id": node_id}
@router.post("/pending/{device_id}/hide") @router.post("/pending/{device_id}/hide")
@@ -78,9 +199,10 @@ async def hide_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]: ) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if not device:
device.status = "hidden" raise HTTPException(status_code=404, detail="Device not found")
await db.commit() device.status = "hidden"
await db.commit()
return {"hidden": True} return {"hidden": True}
@@ -89,9 +211,10 @@ async def ignore_device(
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]: ) -> dict[str, bool]:
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if device: if not device:
await db.delete(device) raise HTTPException(status_code=404, detail="Device not found")
await db.commit() await db.delete(device)
await db.commit()
return {"ignored": True} return {"ignored": True}
@@ -103,18 +226,17 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
@router.get("/config", response_model=ScanConfig) @router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
return ScanConfig( return ScanConfig(ranges=settings.scanner_ranges)
ranges=settings.scanner_ranges,
interval_seconds=settings.status_checker_interval,
)
@router.post("/config", response_model=ScanConfig) @router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
previous = settings.scanner_ranges
settings.scanner_ranges = payload.ranges
try: try:
settings.scanner_ranges = payload.ranges
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides() settings.save_overrides()
return payload return payload
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from 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
+29
View File
@@ -0,0 +1,29 @@
"""App-level settings (status checker interval, etc.)."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.api.deps import get_current_user
from app.core.config import settings
router = APIRouter()
class AppSettings(BaseModel):
interval_seconds: int
@router.get("", response_model=AppSettings)
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
return AppSettings(interval_seconds=settings.status_checker_interval)
@router.post("", response_model=AppSettings)
async def update_settings(
payload: AppSettings, _: str = Depends(get_current_user)
) -> AppSettings:
try:
settings.status_checker_interval = payload.interval_seconds
settings.save_overrides()
return payload
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
+11
View File
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
Path("/app/VERSION"), # Docker image
]:
if candidate.exists():
return candidate.read_text().strip()
return "unknown"
APP_VERSION = _read_version()
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
+76 -24
View File
@@ -1,4 +1,5 @@
"""APScheduler setup for background scan and status check jobs.""" """APScheduler setup for background scan and status check jobs."""
import asyncio
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -15,43 +16,94 @@ logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler() scheduler: AsyncIOScheduler = AsyncIOScheduler()
async def _run_status_checks() -> None: async def _check_single_node(
"""Check all nodes and broadcast results via WebSocket.""" node_id: str,
check_method: str,
check_target: str | None,
ip: str | None,
) -> tuple[str, dict[str, object] | None]:
"""Run a single node check; returns (node_id, result_or_None).
Accepts plain scalars — not an ORM object — so there is no risk of
DetachedInstanceError when the originating session has already closed.
"""
from app.api.routes.status import broadcast_status # avoid circular import from app.api.routes.status import broadcast_status # avoid circular import
try:
check_result = await check_node(check_method, check_target, ip)
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
n = await db.get(Node, node_id)
if n:
n.status = check_result["status"]
n.response_time_ms = check_result["response_time_ms"]
if check_result["status"] == "online":
n.last_seen = now
await db.commit()
await broadcast_status(
node_id=node_id,
status=check_result["status"],
checked_at=now.isoformat(),
response_time_ms=check_result["response_time_ms"],
)
return node_id, check_result
except Exception as exc:
logger.error("Status check failed for node %s: %s", node_id, exc)
return node_id, None
async def _run_status_checks() -> None:
"""Check all nodes concurrently and broadcast results via WebSocket."""
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
result = await db.execute(select(Node)) result = await db.execute(select(Node))
nodes = result.scalars().all() nodes = result.scalars().all()
# Extract scalars while the session is open to avoid DetachedInstanceError
checkable = [
(n.id, n.check_method, n.check_target, n.ip)
for n in nodes
if n.check_method
]
for node in nodes: if not checkable:
if not node.check_method: return
continue
try: await asyncio.gather(*[
check_result = await check_node(node.check_method, node.check_target, node.ip) _check_single_node(node_id, method, target, ip)
async with AsyncSessionLocal() as db: for node_id, method, target, ip in checkable
n = await db.get(Node, node.id) ])
if n:
n.status = check_result["status"]
n.response_time_ms = check_result["response_time_ms"]
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
await db.commit()
await broadcast_status(
node_id=node.id,
status=check_result["status"],
checked_at=datetime.now(timezone.utc).isoformat(),
response_time_ms=check_result["response_time_ms"],
)
except Exception as exc:
logger.error("Status check failed for node %s: %s", node.id, exc)
def start_scheduler() -> None: def start_scheduler() -> None:
global scheduler global scheduler
if scheduler.running:
try:
scheduler.shutdown(wait=False)
except Exception as exc:
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks") scheduler.add_job(
_run_status_checks,
"interval",
seconds=settings.status_checker_interval,
id="status_checks",
max_instances=1,
coalesce=True,
)
scheduler.start() scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval) logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
def reschedule_status_checks(interval_seconds: int) -> None:
"""Update the status check interval on the running scheduler."""
if interval_seconds < 10:
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
scheduler.reschedule_job("status_checks", trigger="interval", seconds=interval_seconds)
logger.info("Status checks rescheduled to every %ds", interval_seconds)
def stop_scheduler() -> None: def stop_scheduler() -> None:
scheduler.shutdown(wait=False) if scheduler.running:
scheduler.shutdown(wait=False)
+51 -1
View File
@@ -1,3 +1,5 @@
import logging
import shutil
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -6,7 +8,9 @@ from sqlalchemy.exc import OperationalError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings from app.core.config import APP_VERSION, settings
logger = logging.getLogger(__name__)
# Ensure the data directory exists before SQLite tries to open the file # Ensure the data directory exists before SQLite tries to open the file
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True) Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
@@ -23,7 +27,22 @@ class Base(DeclarativeBase):
pass pass
def _backup_db() -> None:
db_path = Path(settings.sqlite_path)
if not db_path.exists():
return
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
if backup_path.exists():
return
try:
shutil.copy2(db_path, backup_path)
logger.info("DB backup created: %s", backup_path.name)
except OSError:
logger.warning("Could not create DB backup at %s", backup_path)
async def init_db() -> None: async def init_db() -> None:
_backup_db()
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
# Add columns introduced after initial schema (idempotent) # Add columns introduced after initial schema (idempotent)
@@ -57,6 +76,37 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError):
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for row in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
props = []
visible = bool(show_hardware)
if cpu_model:
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
if cpu_count is not None:
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
if ram_gb is not None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id),
)
# Migrate animated column from boolean (0/1) to string ('none'/'snake') # Migrate animated column from boolean (0/1) to string ('none'/'snake')
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
+4 -1
View File
@@ -42,13 +42,14 @@ class Node(Base):
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True) ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True) disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False) show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
width: Mapped[float | None] = mapped_column(Float, nullable=True) width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True) height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer) response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent") children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id]) parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
@@ -68,6 +69,7 @@ class Edge(Base):
animated: Mapped[str] = mapped_column(String, nullable=False, default='none') animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
source_handle: Mapped[str | None] = mapped_column(String) source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String) target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -90,6 +92,7 @@ class PendingDevice(Base):
services: Mapped[list[Any]] = mapped_column(JSON, default=list) services: Mapped[list[Any]] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String) suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending") status: Mapped[str] = mapped_column(String, default="pending")
discovery_source: Mapped[str | None] = mapped_column(String)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+15 -1
View File
@@ -1,3 +1,5 @@
import logging
import logging.config
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any from typing import Any
@@ -6,6 +8,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
from app.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler from app.core.scheduler import start_scheduler, stop_scheduler
from app.db.database import init_db from app.db.database import init_db
@@ -13,6 +16,16 @@ from app.db.database import init_db
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
# Ensure app logs are visible: attach a handler to the root logger if none
# exists (uvicorn only installs handlers on its own loggers, not the root).
root_logger = logging.getLogger()
if not root_logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
root_logger.addHandler(handler)
root_logger.setLevel(logging.INFO)
logging.getLogger("app").setLevel(logging.INFO)
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
await init_db() await init_db()
settings.load_overrides() settings.load_overrides()
start_scheduler() start_scheduler()
@@ -22,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app = FastAPI( app = FastAPI(
title="Homelable API", title="Homelable API",
version="1.4.0", version="1.9.0",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -40,6 +53,7 @@ app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"]) app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"]) app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"]) app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"]) app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
+3
View File
@@ -29,8 +29,10 @@ class NodeSave(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool = False show_hardware: bool = False
properties: list[Any] = []
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int = 1
pos_x: float = 0 pos_x: float = 0
pos_y: float = 0 pos_y: float = 0
@@ -48,6 +50,7 @@ class EdgeSave(BaseModel):
animated: str = 'none' animated: str = 'none'
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before') @field_validator('animated', mode='before')
@classmethod @classmethod
+2
View File
@@ -17,6 +17,7 @@ class EdgeBase(BaseModel):
animated: str = 'none' animated: str = 'none'
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before') @field_validator('animated', mode='before')
@classmethod @classmethod
@@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel):
animated: str | None = None animated: str | None = None
source_handle: str | None = None source_handle: str | None = None
target_handle: str | None = None target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@field_validator('animated', mode='before') @field_validator('animated', mode='before')
@classmethod @classmethod
+4
View File
@@ -27,8 +27,10 @@ class NodeBase(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool = False show_hardware: bool = False
properties: list[dict[str, Any]] = []
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int = 1
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
@@ -58,8 +60,10 @@ class NodeUpdate(BaseModel):
ram_gb: float | None = None ram_gb: float | None = None
disk_gb: float | None = None disk_gb: float | None = None
show_hardware: bool | None = None show_hardware: bool | None = None
properties: list[dict[str, Any]] | None = None
width: float | None = None width: float | None = None
height: float | None = None height: float | None = None
bottom_handles: int | None = None
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
+1
View File
@@ -13,6 +13,7 @@ class PendingDeviceResponse(BaseModel):
services: list[Any] services: list[Any]
suggested_type: str | None suggested_type: str | None
status: str status: str
discovery_source: str | None
discovered_at: datetime discovered_at: datetime
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
+1 -1
View File
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
return 'snake' return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none': if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none' return 'none'
if v in ('snake', 'flow'): if v in ('snake', 'flow', 'basic'):
return str(v) return str(v)
return 'none' return 'none'
+49 -9
View File
@@ -65,14 +65,46 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
return results return results
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated) # Known OUI prefixes lowercase, colon-separated, first 3 octets
_MAC_OUI_TYPES: dict[str, str] = { _MAC_OUI_TYPES: dict[str, str] = {
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs) # Hypervisors / VMs
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+) "52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
"00:50:56": "vm", # VMware "00:50:56": "vm", # VMware
"00:0c:29": "vm", # VMware Workstation / Fusion "00:0c:29": "vm", # VMware Workstation / Fusion
"08:00:27": "vm", # VirtualBox "08:00:27": "vm", # VirtualBox
"00:15:5d": "vm", # Hyper-V "00:15:5d": "vm", # Hyper-V
# Shelly
"34:94:54": "iot",
"84:f3:eb": "iot",
"ec:fa:bc": "iot",
"30:c6:f7": "iot",
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
"a0:20:a6": "iot",
"24:62:ab": "iot",
"30:ae:a4": "iot",
"cc:50:e3": "iot",
"ac:67:b2": "iot",
"b4:e6:2d": "iot",
"3c:71:bf": "iot",
"8c:aa:b5": "iot",
# Sonoff / ITEAD
"dc:4f:22": "iot",
"e8:db:84": "iot",
# Tapo / TP-Link smart home
"b0:a7:b9": "iot",
"50:c7:bf": "iot",
"1c:3b:f3": "iot",
"10:27:f5": "iot",
# Philips Hue
"00:17:88": "iot",
"ec:b5:fa": "iot",
# IKEA Tradfri
"34:13:e8": "iot",
"00:21:2e": "iot",
# Tuya / Smart Life (widely used chip in many brands)
"d8:f1:5b": "iot",
"68:57:2d": "iot",
} }
@@ -101,10 +133,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
37777: "camera", # Dahua 37777: "camera", # Dahua
34567: "camera", # Amcrest 34567: "camera", # Amcrest
2020: "camera", # Tapo 2020: "camera", # Tapo
# Smart-home / MQTT → iot # Smart-home / MQTT / CoAP → iot
1883: "iot", 1883: "iot",
8883: "iot", 8883: "iot",
6052: "iot", # ESPHome 6052: "iot", # ESPHome dashboard
4915: "iot", # Shelly CoIoT
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
5684: "iot", # CoAP DTLS
# AP / wireless # AP / wireless
8880: "ap", # UniFi HTTP 8880: "ap", # UniFi HTTP
8443: "ap", # UniFi HTTPS 8443: "ap", # UniFi HTTPS
@@ -115,8 +150,13 @@ _PORT_TYPE_HINTS: dict[int, str] = {
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str: def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
"""Suggest a node type based on matched signatures and MAC OUI.""" """Suggest a node type based on matched signatures, port hints, and MAC OUI."""
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"] # IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
mac_type = suggest_type_from_mac(mac)
if mac_type == "iot":
return "iot"
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
found: set[str] = set() found: set[str] = set()
for p in open_ports: for p in open_ports:
port = p["port"] port = p["port"]
@@ -126,10 +166,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
found.add(sig["suggested_node_type"]) found.add(sig["suggested_node_type"])
if port in _PORT_TYPE_HINTS: if port in _PORT_TYPE_HINTS:
found.add(_PORT_TYPE_HINTS[port]) found.add(_PORT_TYPE_HINTS[port])
# MAC OUI is a lower-priority hint — only used if ports give no better answer
mac_type = suggest_type_from_mac(mac)
if mac_type: if mac_type:
found.add(mac_type) found.add(mac_type)
for t in priority: for t in priority:
if t in found: if t in found:
return t return t
+428 -88
View File
@@ -1,17 +1,48 @@
"""Network scanner: ARP sweep + nmap service detection.""" """Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
import asyncio import asyncio
import ipaddress
import logging import logging
import os
import re
import socket import socket
import subprocess
import threading
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.services.fingerprint import fingerprint_ports, suggest_node_type from app.services.fingerprint import fingerprint_ports, suggest_node_type
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Run IDs that have been requested to cancel (thread-safe via lock)
_cancelled_runs: set[str] = set()
_cancelled_lock = threading.Lock()
# Port list for service detection (Phase 2)
_EXTRA_PORTS = (
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
"16686,34567,37777,51413,64738"
)
_MDNS_SERVICE_TYPES = [
"_http._tcp.local.",
"_shelly._tcp.local.",
"_esphomelib._tcp.local.",
"_hap._tcp.local.", # HomeKit Accessory Protocol
"_mqtt._tcp.local.",
"_device-info._tcp.local.",
]
try: try:
import nmap import nmap
_NMAP_AVAILABLE = True _NMAP_AVAILABLE = True
@@ -19,50 +50,24 @@ except ImportError:
_NMAP_AVAILABLE = False _NMAP_AVAILABLE = False
logger.warning("python-nmap not available — scanner will run in mock mode") logger.warning("python-nmap not available — scanner will run in mock mode")
try:
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
_ZEROCONF_AVAILABLE = True
except ImportError:
_ZEROCONF_AVAILABLE = False
logger.warning("zeroconf not available — mDNS discovery disabled")
def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""Run nmap -sV --open on target, return list of host dicts."""
if not _NMAP_AVAILABLE:
return _mock_scan(target)
nm = nmap.PortScanner() def request_cancel(run_id: str) -> None:
try: """Signal a running scan to stop early."""
# Home lab port range: standard top-1000 + common self-hosted service ports with _cancelled_lock:
extra_ports = ( _cancelled_runs.add(run_id)
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
)
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
except Exception as exc:
logger.error("nmap scan failed: %s", exc)
raise RuntimeError(str(exc)) from exc
hosts = []
for host in nm.all_hosts(): def _is_cancelled(run_id: str) -> bool:
if nm[host].state() != "up": with _cancelled_lock:
continue return run_id in _cancelled_runs
open_ports = []
for proto in nm[host].all_protocols():
for port, info in nm[host][proto].items():
if info["state"] == "open":
open_ports.append({
"port": port,
"protocol": proto,
"banner": info.get("product", "") + " " + info.get("version", ""),
})
hosts.append({
"ip": host,
"hostname": _resolve_hostname(host),
"mac": nm[host].get("addresses", {}).get("mac"),
"os": _extract_os(nm, host),
"open_ports": open_ports,
})
return hosts
def _resolve_hostname(ip: str) -> str | None: def _resolve_hostname(ip: str) -> str | None:
@@ -82,6 +87,278 @@ def _extract_os(nm: object, host: str) -> str | None:
return None return None
def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
"""
Read the OS ARP cache for recently-seen hosts in the target network.
Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
Supplements nmap discovery — catches IoT and devices with all ports filtered.
"""
try:
net = ipaddress.ip_network(network, strict=False)
found: dict[str, dict[str, Any]] = {}
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
proc_arp = "/proc/net/arp"
try:
with open(proc_arp) as f:
for line in f.readlines()[1:]: # skip header row
parts = line.split()
if len(parts) >= 4:
ip, mac = parts[0], parts[3]
if mac == "00:00:00:00:00:00":
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {
"ip": ip, "mac": mac,
"hostname": _resolve_hostname(ip),
"os": None, "open_ports": [],
}
except ValueError:
pass
# /proc/net/arp opened successfully — return whatever we found (may be empty)
# Don't fall through to `arp -a` since we're on Linux
return found
except FileNotFoundError:
pass # Not Linux — fall through to macOS `arp -a`
# macOS: parse `arp -a` output
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
for line in result.stdout.splitlines():
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
if not m:
continue
ip, mac = m.group(1), m.group(2)
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
continue
try:
if ipaddress.ip_address(ip) in net:
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
except ValueError:
pass
return found
except Exception as exc:
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
return {}
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
"""
Phase 1: Concurrent ICMP ping sweep + ARP cache.
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
Supplements with the OS ARP cache to catch devices that block ICMP.
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
"""
net = ipaddress.ip_network(target, strict=False)
all_ips = [str(ip) for ip in net.hosts()]
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
sem = asyncio.Semaphore(50)
async def _ping(ip: str) -> str | None:
async with sem:
try:
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "1", ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
return ip if proc.returncode == 0 else None
except Exception:
return None
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
# ARP cache: catch devices that block ICMP but were recently active,
# and enrich ping-alive hosts with their MAC addresses.
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
alive: dict[str, dict[str, Any]] = {}
for ip in alive_ips:
mac = arp_cache.get(ip, {}).get("mac")
hostname = await asyncio.to_thread(_resolve_hostname, ip)
logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
for ip, host in arp_cache.items():
if ip not in alive:
logger.info(
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
)
alive[ip] = host
return alive
def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
"""
Phase 2 — single-IP port scan with service detection.
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
"""
ip = host_dict["ip"]
logger.info("[Phase 2] Scanning %s ...", ip)
if not _NMAP_AVAILABLE:
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
return host_dict
is_root = os.geteuid() == 0
if is_root:
# SYN scan + version detection (fastest, most accurate)
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
else:
# TCP connect scan (-sT) — no raw sockets needed, works without root.
# nmap auto-selects -sT without root but being explicit avoids edge cases.
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
nm = nmap.PortScanner()
try:
nm.scan(hosts=ip, arguments=scan_args)
except Exception as exc:
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
return host_dict
all_scanned = nm.all_hosts()
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
if ip not in all_scanned:
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
return host_dict
open_ports = []
for proto in nm[ip].all_protocols():
for port, info in nm[ip][proto].items():
if info["state"] == "open":
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
open_ports.append({"port": port, "protocol": proto, "banner": banner})
if open_ports:
port_summary = ", ".join(
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
)
logger.info("[Phase 2] %s%d open port(s): %s", ip, len(open_ports), port_summary)
else:
logger.info("[Phase 2] %s — 0 open ports detected", ip)
host_dict["open_ports"] = open_ports
if not host_dict["mac"]:
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
host_dict["os"] = _extract_os(nm, ip)
return host_dict
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
"""
Phase 2: Per-IP service detection with bounded concurrency.
Each host is scanned independently in a thread — no inter-host timeout interference.
Up to 10 hosts scanned concurrently.
"""
if not alive:
return []
logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
semaphore = asyncio.Semaphore(10)
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
async with semaphore:
return await asyncio.to_thread(_nmap_scan_single, host_dict)
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
results = []
for item in raw:
if isinstance(item, BaseException):
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
else:
results.append(item)
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
return results
async def _nmap_scan(target: str) -> list[dict[str, Any]]:
"""
Two-phase scan for a CIDR range.
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
"""
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
if not _NMAP_AVAILABLE:
logger.warning("[Scan] nmap not available — returning mock data")
return _mock_scan(target)
try:
alive = await _ping_sweep(target)
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
len(alive), target, ", ".join(sorted(alive.keys())))
except Exception as exc:
logger.error("Phase 1 ping sweep failed: %s", exc)
raise RuntimeError(str(exc)) from exc
return await _nmap_port_scan(alive)
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
"""
Passive mDNS/Bonjour sweep.
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
Runs for `timeout` seconds then returns what it found.
"""
if not _ZEROCONF_AVAILABLE:
return []
import ipaddress
found_services: list[tuple[str, str]] = []
def _on_change(
zeroconf: Any,
service_type: str,
name: str,
state_change: Any,
) -> None:
if state_change == ServiceStateChange.Added:
found_services.append((service_type, name))
discovered: dict[str, dict[str, Any]] = {}
try:
async with AsyncZeroconf() as azc:
browser = AsyncServiceBrowser(
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
)
await asyncio.sleep(timeout)
await browser.async_cancel()
for service_type, name in found_services:
try:
info = AsyncServiceInfo(service_type, name)
await info.async_request(azc.zeroconf, 3000)
if not info.addresses:
continue
ip = str(ipaddress.IPv4Address(info.addresses[0]))
if ip in discovered:
continue
discovered[ip] = {
"ip": ip,
"hostname": info.server,
"mac": None,
"os": None,
"open_ports": (
[{"port": info.port, "protocol": "tcp", "banner": ""}]
if info.port else []
),
}
except Exception as exc:
logger.debug("mDNS resolution failed for %s: %s", name, exc)
except Exception as exc:
logger.warning("mDNS discovery error: %s", exc)
logger.info("mDNS discovery found %d device(s)", len(discovered))
return list(discovered.values())
def _mock_scan(target: str) -> list[dict[str, Any]]: def _mock_scan(target: str) -> list[dict[str, Any]]:
"""Return fake results for dev/test environments without nmap.""" """Return fake results for dev/test environments without nmap."""
return [ return [
@@ -100,73 +377,136 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
"""Execute scan for given CIDR ranges and populate pending_devices.""" """Execute scan for given CIDR ranges and populate pending_devices."""
# Avoid circular import
from sqlalchemy import select
from app.api.routes.status import broadcast_scan_update from app.api.routes.status import broadcast_scan_update
devices_found = 0 devices_found = 0
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
try: try:
for cidr in ranges: # Validate all ranges are valid CIDRs before passing anything to nmap
# Run nmap in a thread pool — does not block the event loop for r in ranges:
hosts = await asyncio.to_thread(_nmap_scan, cidr) try:
ipaddress.ip_network(r, strict=False)
except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None
for host in hosts: # Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
services = fingerprint_ports(host["open_ports"]) canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
# Update existing pending device or create a new one hidden_ips_result = await db.execute(
existing_result = await db.execute( select(PendingDevice.ip).where(PendingDevice.status == "hidden")
select(PendingDevice).where( )
PendingDevice.ip == host["ip"], hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
PendingDevice.status == "pending",
) # Clean up stale pending devices whose IPs are already in the canvas
if canvas_ips:
from sqlalchemy import delete as sa_delete
await db.execute(
sa_delete(PendingDevice).where(
PendingDevice.status == "pending",
PendingDevice.ip.in_(canvas_ips),
) )
existing = existing_result.scalar_one_or_none() )
if existing: await db.commit()
existing.mac = host.get("mac") or existing.mac
existing.hostname = host.get("hostname") or existing.hostname
existing.os = host.get("os") or existing.os
existing.services = services
existing.suggested_type = suggested_type
else:
device = PendingDevice(
ip=host["ip"],
mac=host.get("mac"),
hostname=host.get("hostname"),
os=host.get("os"),
services=services,
suggested_type=suggested_type,
status="pending",
)
db.add(device)
devices_found += 1
# Commit immediately so the device is visible right away # Start mDNS discovery in the background while nmap scans run
await db.commit() mdns_task = asyncio.create_task(_mdns_discover())
# Update running count on the scan run record # Track IPs found by nmap so mDNS doesn't duplicate them
run = await db.get(ScanRun, run_id) nmap_ips: set[str] = set()
if run:
run.devices_found = devices_found
await db.commit()
# Push WS event so the frontend refreshes pending panel async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
await broadcast_scan_update(run_id=run_id, devices_found=devices_found) nonlocal devices_found
ip = host["ip"]
# Mark scan as done # Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
if ip in canvas_ips:
logger.debug("Skipping %s — already in canvas", ip)
return
if ip in hidden_ips:
logger.debug("Skipping %s — hidden by user", ip)
return
services = fingerprint_ports(host["open_ports"])
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
existing_result = await db.execute(
select(PendingDevice).where(
PendingDevice.ip == ip,
PendingDevice.status == "pending",
)
)
existing = existing_result.scalar_one_or_none()
if existing:
existing.mac = host.get("mac") or existing.mac
existing.hostname = host.get("hostname") or existing.hostname
existing.os = host.get("os") or existing.os
existing.services = services
existing.suggested_type = suggested_type
else:
db.add(PendingDevice(
ip=ip,
mac=host.get("mac"),
hostname=host.get("hostname"),
os=host.get("os"),
services=services,
suggested_type=suggested_type,
status="pending",
discovery_source=discovery_source,
))
devices_found += 1
await db.commit()
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
# nmap scan per CIDR — results stream in progressively
for cidr in ranges:
if _is_cancelled(run_id):
break
hosts = await _nmap_scan(cidr)
for host in hosts:
if _is_cancelled(run_id):
break
nmap_ips.add(host["ip"])
await _process_host(host)
# Update ScanRun count once after all CIDR ranges
run = await db.get(ScanRun, run_id) run = await db.get(ScanRun, run_id)
if run: if run:
run.status = "done" run.devices_found = devices_found
await db.commit()
# Collect mDNS results — task already has its own 4s internal timeout
if not _is_cancelled(run_id):
mdns_hosts = await mdns_task
for host in mdns_hosts:
if _is_cancelled(run_id):
break
if host["ip"] in nmap_ips:
continue # already processed with richer nmap data
await _process_host(host, discovery_source="mdns")
else:
mdns_task.cancel()
# Mark scan as done or cancelled
run = await db.get(ScanRun, run_id)
if run:
run.status = "cancelled" if _is_cancelled(run_id) else "done"
run.devices_found = devices_found run.devices_found = devices_found
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
except Exception as exc: except Exception as exc:
logger.error("Scan failed: %s", exc) logger.error("Scan failed: %s", exc)
if mdns_task is not None and not mdns_task.done():
mdns_task.cancel()
run = await db.get(ScanRun, run_id) run = await db.get(ScanRun, run_id)
if run: if run:
run.status = "error" run.status = "error"
run.error = str(exc) run.error = str(exc)
run.finished_at = datetime.now(timezone.utc) run.finished_at = datetime.now(timezone.utc)
await db.commit() await db.commit()
finally:
with _cancelled_lock:
_cancelled_runs.discard(run_id)
+9 -2
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import logging import logging
import socket import socket
import sys
import time import time
from typing import Any from typing import Any
@@ -18,7 +19,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
if check_method == "none": if check_method == "none":
return {"status": "online", "response_time_ms": None} return {"status": "online", "response_time_ms": None}
host = target or ip # Use only the first IP when the field contains comma-separated addresses
raw_ip = ip.split(",")[0].strip() if ip else None
host = target or raw_ip
if not host: if not host:
return {"status": "unknown", "response_time_ms": None} return {"status": "unknown", "response_time_ms": None}
@@ -57,8 +60,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
async def _ping(host: str) -> bool: async def _ping(host: str) -> bool:
if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host]
else:
args = ["ping", "-c", "1", "-W", "1", host]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", "1", host, *args,
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL,
) )
+1
View File
@@ -2,3 +2,4 @@
*.db-shm *.db-shm
*.db-wal *.db-wal
scan_config.json scan_config.json
homelab.db.*
+2
View File
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
[tool.coverage.run] [tool.coverage.run]
source = ["app"] source = ["app"]
omit = ["*/migrations/*", "*/tests/*"] omit = ["*/migrations/*", "*/tests/*"]
concurrency = ["thread"]
core = "sysmon"
[tool.coverage.report] [tool.coverage.report]
skip_empty = true skip_empty = true
+4 -3
View File
@@ -9,17 +9,18 @@ pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0 python-jose[cryptography]==3.5.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1 bcrypt==4.0.1
python-multipart==0.0.22 python-multipart==0.0.26
apscheduler==3.10.4 apscheduler==3.10.4
python-nmap==0.7.1 python-nmap==0.7.1
pyyaml==6.0.2 pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.131.0
# Dev # Dev
ruff==0.6.9 ruff==0.6.9
mypy==1.11.2 mypy==1.11.2
pytest==8.3.3 pytest==9.0.3
pytest-asyncio==0.24.0 pytest-asyncio==1.3.0
pytest-cov==5.0.0 pytest-cov==5.0.0
+301
View File
@@ -256,3 +256,304 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
canvas = (await client.get("/api/v1/canvas", headers=headers)).json() canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["width"] is None assert canvas["nodes"][0]["width"] is None
assert canvas["nodes"][0]["height"] is None assert canvas["nodes"][0]["height"] is None
# ── properties ────────────────────────────────────────────────────────────────
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
n1 = node_payload()
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]["properties"] == []
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
props = [
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
]
n1 = node_payload(properties=props)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["nodes"][0]["properties"]
assert len(returned) == 2
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_updated = {**n1, "properties": [
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
props = canvas["nodes"][0]["properties"]
assert len(props) == 2
assert props[0]["value"] == "64 GB"
assert props[1]["key"] == "Disk"
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
n1 = node_payload(properties=props)
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]["properties"][0]["icon"] is None
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "properties": []}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"][0]["properties"] == []
# ── edge waypoints & handles ──────────────────────────────────────────────────
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] is None
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
returned = canvas["edges"][0]["waypoints"]
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=3)
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["source_handle"] == "bottom-1"
assert edge["target_handle"] == "top"
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "snake"
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["edges"][0]["animated"] == "basic"
# ── node fields ───────────────────────────────────────────────────────────────
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
n1 = node_payload(
type="server",
label="Main Server",
hostname="server.local",
ip="192.168.1.10",
mac="aa:bb:cc:dd:ee:ff",
os="Ubuntu 22.04",
status="online",
check_method="http",
check_target="http://192.168.1.10",
services=[{"name": "nginx", "port": 80}],
notes="Primary web server",
pos_x=150.0,
pos_y=250.0,
bottom_handles=2,
)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["hostname"] == "server.local"
assert node["ip"] == "192.168.1.10"
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
assert node["os"] == "Ubuntu 22.04"
assert node["status"] == "online"
assert node["check_method"] == "http"
assert node["check_target"] == "http://192.168.1.10"
assert node["services"] == [{"name": "nginx", "port": 80}]
assert node["notes"] == "Primary web server"
assert node["pos_x"] == 150.0
assert node["pos_y"] == 250.0
assert node["bottom_handles"] == 2
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
n1 = node_payload(bottom_handles=4)
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]["bottom_handles"] == 4
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
n1 = node_payload()
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]["bottom_handles"] == 1
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
n1 = node_payload(services=services, notes="My NAS device")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["services"] == services
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)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["check_method"] == "ping"
assert node["check_target"] == "192.168.1.1"
# ── parent/child nodes ────────────────────────────────────────────────────────
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[child["id"]]["parent_id"] == parent["id"]
assert node_map[parent["id"]]["parent_id"] is None
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
parent = node_payload(type="proxmox", label="PVE Host")
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
# Remove both parent and child
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["nodes"] == []
# ── groupRect / group node ────────────────────────────────────────────────────
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
member = node_payload(type="server", label="Member", parent_id=group["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node_map = {n["id"]: n for n in canvas["nodes"]}
assert node_map[group["id"]]["type"] == "group"
assert node_map[group["id"]]["width"] == 400.0
assert node_map[group["id"]]["height"] == 300.0
assert node_map[member["id"]]["parent_id"] == group["id"]
# ── viewport ──────────────────────────────────────────────────────────────────
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
# ── edge types ────────────────────────────────────────────────────────────────
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["type"] == "vlan"
assert edge["vlan_id"] == 10
assert edge["label"] == "VLAN 10"
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
"""Second save updates an existing edge (exercises the db_edge branch)."""
n1 = node_payload()
n2 = node_payload()
e1 = edge_payload(n1["id"], n2["id"], label="original")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
edge = canvas["edges"][0]
assert edge["label"] == "updated"
assert edge["custom_color"] == "#ff0000"
+57
View File
@@ -0,0 +1,57 @@
"""
Tests for automatic DB backup before migrations.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
from pathlib import Path
from unittest.mock import patch
import pytest
from app.db.database import _backup_db
@pytest.fixture()
def tmp_db(tmp_path: Path):
db = tmp_path / "homelab.db"
db.write_bytes(b"SQLite placeholder")
return db
def test_backup_created_when_db_exists(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
assert backup.exists()
assert backup.read_bytes() == b"SQLite placeholder"
def test_backup_skipped_when_db_missing(tmp_path: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
_backup_db()
assert not any(tmp_path.glob("*.back-*"))
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "1.9"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
backup = tmp_db.parent / "homelab.db.back-1.9"
backup.write_bytes(b"original backup")
_backup_db()
assert backup.read_bytes() == b"original backup"
def test_backup_version_in_filename(tmp_db: Path):
with patch("app.db.database.settings") as mock_settings, \
patch("app.db.database.APP_VERSION", "2.0"):
mock_settings.sqlite_path = str(tmp_db)
_backup_db()
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
+42
View File
@@ -131,3 +131,45 @@ def test_suggest_node_type_camera_from_signature():
]): ]):
result = suggest_node_type([{"port": 554, "protocol": "tcp"}]) result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
assert result == "camera" assert result == "camera"
# ── IoT detection ─────────────────────────────────────────────────────────────
def test_suggest_node_type_iot_from_mqtt_port():
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_coap_port():
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_iot_from_esphome_port():
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
assert result == "iot"
def test_suggest_node_type_shelly_mac_overrides_http_port():
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_espressif_mac_returns_iot():
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
assert result == "iot"
def test_suggest_node_type_tuya_mac_returns_iot():
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
assert result == "iot"
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
result = suggest_node_type([
{"port": 80, "protocol": "tcp"},
{"port": 1883, "protocol": "tcp"},
])
assert result == "iot"
+94
View File
@@ -115,3 +115,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
async def test_create_node_requires_auth(client: AsyncClient): async def test_create_node_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"}) res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
assert res.status_code == 401 assert res.status_code == 401
# --- Properties tests ---
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
"""New node has an empty properties list by default."""
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
assert res.status_code == 201
assert res.json()["properties"] == []
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
"""Node created with properties round-trips correctly."""
props = [
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
]
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
assert res.status_code == 201
assert res.json()["properties"] == props
async def test_patch_node_properties(client: AsyncClient, headers: dict):
"""PATCH with properties replaces the full properties array."""
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
node_id = create.json()["id"]
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
"""PATCH that omits properties leaves existing properties untouched."""
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
# PATCH only the label — properties must survive
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
assert res.json()["label"] == "Updated"
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
"""PATCH with properties=[] explicitly clears all properties."""
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == []
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
"""GET /nodes/:id returns the properties field."""
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
create = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
headers=headers,
)
node_id = create.json()["id"]
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
assert res.status_code == 200
assert res.json()["properties"] == props
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
"""A property with icon=null is valid and round-trips correctly."""
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
create = await client.post(
"/api/v1/nodes",
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
headers=headers,
)
assert create.status_code == 201
assert create.json()["properties"] == props
+176
View File
@@ -0,0 +1,176 @@
"""
Tests for the hardware → properties migration logic.
We test the migration function directly against an in-memory SQLite database
so we can set up legacy rows (with hardware columns, NULL properties) and
verify the migration produces the expected properties JSON.
"""
import json
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_legacy_table(conn):
"""Create a minimal nodes table that mimics the pre-migration schema."""
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 '',
cpu_model TEXT,
cpu_count INTEGER,
ram_gb REAL,
disk_gb REAL,
show_hardware BOOLEAN NOT NULL DEFAULT 0,
properties JSON
)
""")
async def _run_migration(conn):
"""Run only the properties migration portion (extracted from init_db)."""
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for row in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
props = []
visible = bool(show_hardware)
if cpu_model:
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
if cpu_count is not None:
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
if ram_gb is not None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(json.dumps(props), node_id),
)
async def _get_properties(conn, node_id: str) -> list:
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
raw = rows.fetchone()[0]
return json.loads(raw) if raw else []
@pytest.mark.asyncio
async def test_migration_full_hardware():
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
"VALUES (?, ?, ?, ?, ?, ?)",
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-1")
assert len(props) == 4
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_partial_hardware():
"""Node with only cpu_model and ram_gb → 2 property entries."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
("node-2", "Ryzen 5 5600", 16.0, 0),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-2")
assert len(props) == 2
assert props[0]["key"] == "CPU Model"
assert props[0]["visible"] is False
assert props[1]["key"] == "RAM"
assert props[1]["icon"] == "MemoryStick"
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_no_hardware():
"""Node with no hardware fields → empty properties array."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id) VALUES (?)",
("node-3",),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-3")
assert props == []
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_idempotent():
"""Running migration twice does not duplicate properties."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
("node-4", "Core i5", 1),
)
await _run_migration(conn)
await _run_migration(conn) # second pass — node already has properties, should be skipped
props = await _get_properties(conn, "node-4")
assert len(props) == 1
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_show_hardware_false_sets_visible_false():
"""show_hardware=0 means all migrated properties have visible=False."""
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
("node-5", "ARM Cortex-A72", 4.0, 0),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-5")
assert all(p["visible"] is False for p in props)
await engine.dispose()
@pytest.mark.asyncio
async def test_migration_already_migrated_node_not_touched():
"""Node that already has properties is skipped — existing properties preserved."""
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
engine = create_async_engine(TEST_DB_URL)
async with engine.begin() as conn:
await _setup_legacy_table(conn)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
)
await _run_migration(conn)
props = await _get_properties(conn, "node-6")
assert props == existing
await engine.dispose()
+310 -5
View File
@@ -1,4 +1,4 @@
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore.""" """Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
import uuid import uuid
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
@@ -7,8 +7,8 @@ from httpx import AsyncClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import PendingDevice, ScanRun from app.db.models import Node, PendingDevice, ScanRun
from app.services.scanner import run_scan from app.services.scanner import _cancelled_runs, request_cancel, run_scan
@pytest.fixture @pytest.fixture
@@ -120,8 +120,7 @@ async def test_approve_nonexistent_device(client: AsyncClient, headers):
json=node_payload, json=node_payload,
headers=headers, headers=headers,
) )
assert res.status_code == 200 assert res.status_code == 404
assert res.json()["approved"] is False
# --- Hide device --- # --- Hide device ---
@@ -199,6 +198,213 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
assert device.suggested_type == "server" assert device.suggested_type == "server"
@pytest.mark.asyncio
async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
"""Pending devices that were already in canvas before scan starts must be removed."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
stale = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(node)
db_session.add(stale)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
"""Devices whose IP already exists as a canvas Node must not appear in pending."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
db_session.add(node)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
hidden = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
mac=None,
hostname=None,
os=None,
services=[],
suggested_type="generic",
status="hidden",
)
db_session.add(hidden)
await db_session.commit()
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(PendingDevice).where(
PendingDevice.ip == "192.168.1.50",
PendingDevice.status == "pending",
)
)
assert result.scalar_one_or_none() is None
# --- Stop scan ---
@pytest.mark.asyncio
async def test_stop_scan_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/scan/fake-id/stop")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_stop_scan_not_found(client: AsyncClient, headers):
import uuid as _uuid
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 409
@pytest.mark.asyncio
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
assert res.status_code == 200
assert res.json() == {"stopping": True}
# run_id added to cancel set
assert run.id in _cancelled_runs
# cleanup for other tests
_cancelled_runs.discard(run.id)
# --- run_scan cancellation ---
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
db_session.add(run)
await db_session.commit()
request_cancel(run_id)
with (
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
# nmap should not have been called — cancelled before first range
mock_nmap.assert_not_called()
await db_session.refresh(run)
assert run.status == "cancelled"
assert run.finished_at is not None
@pytest.mark.asyncio
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
run_id = str(uuid.uuid4())
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
db_session.add(run)
await db_session.commit()
call_count = 0
def nmap_side_effect(target: str):
nonlocal call_count
call_count += 1
# Signal cancellation after the first CIDR scan completes
if call_count == 1:
request_cancel(run_id)
return []
with (
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
):
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
assert call_count == 1 # second CIDR was skipped
await db_session.refresh(run)
assert run.status == "cancelled"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession): async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
"""Re-scanning the same IP updates services instead of creating a duplicate.""" """Re-scanning the same IP updates services instead of creating a duplicate."""
@@ -237,3 +443,102 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
# Services and hostname should be updated # Services and hostname should be updated
assert device.hostname == "myhost.lan" assert device.hostname == "myhost.lan"
assert any(s["port"] == 8096 for s in device.services) assert any(s["port"] == 8096 for s in device.services)
# --- Bulk approve ---
@pytest.fixture
async def two_pending_devices(db_session):
devices = []
for i in range(2):
d = PendingDevice(
id=str(uuid.uuid4()),
ip=f"192.168.1.{10 + i}",
mac=None,
hostname=f"host-{i}",
os=None,
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(d)
devices.append(d)
await db_session.commit()
for d in devices:
await db_session.refresh(d)
return devices
@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
assert res.status_code == 200
data = res.json()
assert data["approved"] == 2
assert len(data["node_ids"]) == 2
assert 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
+535
View File
@@ -0,0 +1,535 @@
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.db.database import Base
from app.db.models import Node, PendingDevice, ScanRun
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_run_id() -> str:
return str(uuid.uuid4())
@pytest.fixture
async def mem_db():
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
yield factory
await engine.dispose()
def _make_scan_run(run_id: str) -> ScanRun:
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
# ---------------------------------------------------------------------------
# _ping_sweep
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ping_sweep_returns_alive_hosts():
from app.services.scanner import _ping_sweep
async def fake_ping(ip: str) -> str | None:
return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
with patch("app.services.scanner._ping_sweep", wraps=None):
pass # just ensure import is fine
# Patch asyncio.create_subprocess_exec to simulate ping responses
responding = {"192.168.1.1", "192.168.1.2"}
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip in responding else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
assert "192.168.1.1" in result
assert "192.168.1.2" in result
for host in result.values():
assert host["open_ports"] == []
@pytest.mark.asyncio
async def test_ping_sweep_excludes_non_responding():
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value={}), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert "192.168.1.1" in result
assert "192.168.1.2" not in result
@pytest.mark.asyncio
async def test_ping_sweep_supplements_with_arp_cache():
"""Devices that block ICMP but appear in ARP cache should still be discovered."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
proc = MagicMock()
proc.returncode = 1 # all pings fail
proc.wait = AsyncMock(return_value=1)
return proc
arp_extra = {
"192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/24")
assert "192.168.1.10" in result
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
@pytest.mark.asyncio
async def test_ping_sweep_enriches_mac_from_arp_cache():
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
from app.services.scanner import _ping_sweep
async def mock_subprocess(*args, **kwargs):
ip = args[-1]
proc = MagicMock()
proc.returncode = 0 if ip == "192.168.1.1" else 1
proc.wait = AsyncMock(return_value=proc.returncode)
return proc
arp_extra = {
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
}
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = await _ping_sweep("192.168.1.0/30")
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
# ---------------------------------------------------------------------------
# _arp_table_hosts
# ---------------------------------------------------------------------------
def test_arp_table_hosts_parses_proc_net_arp():
import io # noqa: PLC0415
from app.services.scanner import _arp_table_hosts
arp_content = (
"IP address HW type Flags HW address Mask Device\n"
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
)
mock_file = MagicMock()
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
mock_file.__exit__ = MagicMock(return_value=False)
with patch("builtins.open", return_value=mock_file), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.50" in result
assert "10.0.0.1" not in result # outside target subnet
assert "192.168.1.99" not in result # zero MAC skipped
def test_arp_table_hosts_parses_macos_arp_output():
from app.services.scanner import _arp_table_hosts
arp_output = (
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
)
mock_result = MagicMock()
mock_result.stdout = arp_output
with patch("builtins.open", side_effect=FileNotFoundError), \
patch("subprocess.run", return_value=mock_result), \
patch("app.services.scanner._resolve_hostname", return_value=None):
result = _arp_table_hosts("192.168.1.0/24")
assert "192.168.1.1" in result
assert "192.168.1.20" in result
assert "192.168.1.99" not in result # incomplete MAC
assert "10.0.0.1" not in result # outside subnet
# ---------------------------------------------------------------------------
# _nmap_scan_single (Phase 2 per-IP worker)
# ---------------------------------------------------------------------------
def test_nmap_scan_single_detects_open_ports():
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
# Build a realistic host entry: protocols → ports → port info
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
mock_host = MagicMock()
mock_host.all_protocols.return_value = ["tcp"]
mock_host.__getitem__ = MagicMock(return_value=port_info)
mock_host.get.return_value = {}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = ["192.168.1.10"]
mock_nm.__getitem__ = MagicMock(return_value=mock_host)
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
patch("app.services.scanner._extract_os", return_value=None):
result = _nmap_scan_single(host)
assert len(result["open_ports"]) == 1
assert result["open_ports"][0]["port"] == 80
assert result["open_ports"][0]["banner"] == "nginx 1.24"
def test_nmap_scan_single_returns_host_unchanged_on_error():
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}
mock_nm = MagicMock()
mock_nm.scan.side_effect = Exception("nmap error")
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_scan_single(host)
assert result["ip"] == "192.168.1.20"
assert result["open_ports"] == []
def test_nmap_scan_single_returns_host_unchanged_when_no_results():
"""Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
from app.services.scanner import _nmap_scan_single
host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
mock_nm = MagicMock()
mock_nm.all_hosts.return_value = [] # no results
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
result = _nmap_scan_single(host)
assert result["ip"] == "192.168.1.30"
assert result["open_ports"] == []
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
# ---------------------------------------------------------------------------
# _nmap_scan
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_scan_uses_mock_when_nmap_unavailable():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._NMAP_AVAILABLE", False):
result = await _nmap_scan("192.168.1.0/24")
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.99"
@pytest.mark.asyncio
async def test_nmap_scan_raises_on_sweep_error():
from app.services.scanner import _nmap_scan
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
pytest.raises(RuntimeError, match="ping sweep failed"):
await _nmap_scan("192.168.1.0/24")
# ---------------------------------------------------------------------------
# _mdns_discover
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
from app.services.scanner import _mdns_discover
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
result = await _mdns_discover()
assert result == []
@pytest.mark.asyncio
async def test_mdns_discover_returns_devices():
from app.services.scanner import _mdns_discover
mock_info = MagicMock()
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
mock_info.server = "shelly1.local."
mock_info.port = 80
mock_info.async_request = AsyncMock(return_value=True)
mock_browser = AsyncMock()
mock_browser.async_cancel = AsyncMock()
# Simulate a service being found during the sleep
captured_handler: list = []
def fake_browser(zc, types, handlers):
captured_handler.extend(handlers)
return mock_browser
from zeroconf import ServiceStateChange
async def fake_sleep(t):
# Fire the handler as if a device was discovered
for h in captured_handler:
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
mock_azc = AsyncMock()
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
mock_azc.__aexit__ = AsyncMock(return_value=None)
mock_azc.zeroconf = MagicMock()
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
patch("asyncio.sleep", side_effect=fake_sleep):
result = await _mdns_discover(timeout=0.01)
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.80"
assert result[0]["hostname"] == "shelly1.local."
# ---------------------------------------------------------------------------
# _nmap_port_scan (Phase 2 concurrency)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
from app.services.scanner import _nmap_port_scan
result = await _nmap_port_scan({})
assert result == []
@pytest.mark.asyncio
async def test_nmap_port_scan_tolerates_single_host_exception():
"""A single per-host failure should not abort the entire Phase 2 gather."""
from app.services.scanner import _nmap_port_scan
hosts = {
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
}
call_count = 0
def _flaky_scan(host_dict):
nonlocal call_count
call_count += 1
if host_dict["ip"] == "192.168.1.1":
raise RuntimeError("simulated nmap crash")
return host_dict
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
patch("app.services.scanner._NMAP_AVAILABLE", True):
result = await _nmap_port_scan(hosts)
assert call_count == 2
# The crashing host is dropped; the healthy one survives
assert len(result) == 1
assert result[0]["ip"] == "192.168.1.2"
# ---------------------------------------------------------------------------
# run_scan integration
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice))
devices = result.scalars().all()
assert any(d.ip == "192.168.1.5" for d in devices)
@pytest.mark.asyncio
async def test_run_scan_mdns_only_device_added(mem_db):
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
assert device.discovery_source == "mdns"
@pytest.mark.asyncio
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
devices = result.scalars().all()
assert len(devices) == 1 # not duplicated
@pytest.mark.asyncio
async def test_run_scan_skips_canvas_nodes(mem_db):
"""Hosts already approved onto the canvas must be skipped."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
canvas_node = Node(
id=str(uuid.uuid4()), label="PVE", type="proxmox",
ip="192.168.1.100", status="online",
)
session.add(canvas_node)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_skips_hidden_devices(mem_db):
"""Hosts hidden by the user must not re-appear in pending."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
session.add(hidden)
await session.commit()
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
result = await session.execute(
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
)
assert result.scalar_one_or_none() is None
@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
from app.services.scanner import request_cancel, run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
await session.commit()
request_cancel(run_id)
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=[]), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
run = await session.get(ScanRun, run_id)
assert run is not None
assert run.status == "cancelled"
+47
View File
@@ -0,0 +1,47 @@
"""Tests for GET/POST /api/v1/settings."""
from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_get_settings_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/settings")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_get_settings_returns_interval(client: AsyncClient, headers):
res = await client.get("/api/v1/settings", headers=headers)
assert res.status_code == 200
data = res.json()
assert "interval_seconds" in data
assert isinstance(data["interval_seconds"], int)
@pytest.mark.asyncio
async def test_update_settings_saves_interval(client: AsyncClient, headers):
with patch("app.api.routes.settings.settings") as mock_settings:
mock_settings.status_checker_interval = 60
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 120},
headers=headers,
)
assert res.status_code == 200
assert res.json()["interval_seconds"] == 120
@pytest.mark.asyncio
async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
assert res.status_code == 401
+43 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from app.services.status_checker import _tcp_connect, check_node from app.services.status_checker import _ping, _tcp_connect, check_node
# --- check_node dispatcher --- # --- check_node dispatcher ---
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
assert result["response_time_ms"] is None assert result["response_time_ms"] is None
# --- _ping platform args ---
@pytest.mark.asyncio
async def test_ping_uses_unix_args_on_non_windows():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "linux"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-c" in captured["args"]
assert "-W" in captured["args"]
assert "-n" not in captured["args"]
@pytest.mark.asyncio
async def test_ping_uses_windows_args_on_win32():
captured = {}
async def fake_exec(*args, **kwargs):
captured["args"] = args
proc = MagicMock()
proc.returncode = 0
proc.wait = AsyncMock()
return proc
with patch("app.services.status_checker.sys.platform", "win32"), \
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
await _ping("192.168.1.1")
assert "-n" in captured["args"]
assert "-w" in captured["args"]
assert "-c" not in captured["args"]
# --- _tcp_connect --- # --- _tcp_connect ---
@pytest.mark.asyncio @pytest.mark.asyncio
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Background -->
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<!-- House body -->
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<!-- Floor line (subtle) -->
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<!-- Door -->
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<!-- Network lines (drawn under nodes) -->
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<!-- Center hub glow -->
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<!-- Center hub -->
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<!-- Left node -->
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<!-- Right node -->
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<!-- Top node -->
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<g transform="translate(4, 4)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+48
View File
@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
<defs>
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
</radialGradient>
<filter id="node-glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Icon (72×72, scaled from 64 viewBox) -->
<g transform="translate(4, 4) scale(1)">
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
<rect x="27" y="40" width="10" height="13" rx="1.5"
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
</g>
<!-- Text -->
<text x="80" y="42"
font-family="Inter, system-ui, -apple-system, sans-serif"
font-weight="600"
font-size="30"
letter-spacing="-0.5">
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
</text>
<!-- Subtitle -->
<text x="81" y="58"
font-family="Inter, system-ui, -apple-system, sans-serif"
font-weight="400"
font-size="11"
fill="#8b949e"
letter-spacing="0.5">HomeLab Visualizer</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1204 -879
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.5.0", "version": "1.10.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -19,6 +19,7 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
@@ -53,7 +54,7 @@
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"lucide-react": "^0.577.0", "lucide-react": "^1.7.0",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
+90 -31
View File
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export' import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml' import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
@@ -33,9 +33,10 @@ import type { NodeData, EdgeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
export default function App() { export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore() const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore() const { activeTheme, setTheme } = useThemeStore()
@@ -44,6 +45,8 @@ export default function App() {
const [themeModalOpen, setThemeModalOpen] = useState(false) const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
const [shortcutsOpen, setShortcutsOpen] = useState(false) const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
@@ -51,6 +54,7 @@ export default function App() {
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null) const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null) const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false) const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [exportModalOpen, setExportModalOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
@@ -97,11 +101,11 @@ export default function App() {
.then((res) => { .then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) { if (apiNodes.length > 0) {
// Build a map of proxmox container mode to know if children should be nested // Build a map of container mode nodes to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>( const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox') .filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, n.container_mode !== false]) .map((n) => [n.id, true])
) )
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
@@ -148,7 +152,7 @@ export default function App() {
const handleAddNode = useCallback((data: Partial<NodeData>) => { const handleAddNode = useCallback((data: Partial<NodeData>) => {
snapshotHistory() snapshotHistory()
const id = generateUUID() const id = generateUUID()
const isProxmox = data.type === 'proxmox' const isContainerNode = data.container_mode === true
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null 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 // Children position is relative to parent; place near top-left with padding
const position = parentNode const position = parentNode
@@ -161,7 +165,7 @@ export default function App() {
position, position,
data: { status: 'unknown', services: [], ...data } as NodeData, data: { status: 'unknown', services: [], ...data } as NodeData,
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}), ...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
...(isProxmox ? { width: 300, height: 200 } : {}), ...(isContainerNode ? { width: 300, height: 200 } : {}),
} }
addNode(newNode) addNode(newNode)
toast.success(`Added "${data.label}"`) toast.success(`Added "${data.label}"`)
@@ -239,13 +243,13 @@ export default function App() {
snapshotHistory() snapshotHistory()
const existingNode = nodes.find((n) => n.id === editNodeId) const existingNode = nodes.find((n) => n.id === editNodeId)
updateNode(editNodeId, data) updateNode(editNodeId, data)
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions) // If container_mode changed, apply structural changes (children parentId, node dimensions)
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') { if (typeof data.container_mode === 'boolean') {
setProxmoxContainerMode(editNodeId, data.container_mode) setProxmoxContainerMode(editNodeId, data.container_mode)
} }
// Sync virtual edge when parent_id changes on an LXC/VM node // Sync virtual edge when parent_id changes on an LXC/VM node
const nodeType = data.type ?? existingNode?.data.type const nodeType = data.type ?? existingNode?.data.type
if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) { if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) {
const oldParentId = existingNode?.data.parent_id ?? null const oldParentId = existingNode?.data.parent_id ?? null
const newParentId = data.parent_id ?? null const newParentId = data.parent_id ?? null
if (oldParentId !== newParentId) { if (oldParentId !== newParentId) {
@@ -258,10 +262,13 @@ export default function App() {
) )
if (oldEdge) deleteEdge(oldEdge.id) if (oldEdge) deleteEdge(oldEdge.id)
} }
// Create new virtual edge: LXC top → Proxmox bottom // Create virtual edge only when parent is NOT in container mode
// (container mode shows containment visually — no edge needed)
if (newParentId) { if (newParentId) {
// Pass type as extra field — canvasStore.onConnect casts to Connection & Partial<EdgeData> const parentNode = nodes.find((n) => n.id === newParentId)
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection) if (!parentNode?.data.container_mode) {
onConnect({ source: editNodeId, sourceHandle: 'top', target: newParentId, targetHandle: 'bottom', type: 'virtual' } as unknown as Connection)
}
} }
} }
} }
@@ -300,15 +307,10 @@ export default function App() {
} }
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
const handleExport = useCallback(async () => { const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow') const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return } if (!el) { toast.error('Canvas not ready'); return }
try { setExportModalOpen(true)
await exportToPng(el)
toast.success('Exported as PNG')
} catch {
toast.error('Export failed')
}
}, []) }, [])
const handleEdgeConnect = useCallback((connection: Connection) => { const handleEdgeConnect = useCallback((connection: Connection) => {
@@ -319,15 +321,19 @@ export default function App() {
if (!pendingConnection) return if (!pendingConnection) return
snapshotHistory() snapshotHistory()
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection) onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id // When a virtual edge is drawn between a child node and a container node, sync parent_id
if (edgeData.type === 'virtual') { if (edgeData.type === 'virtual') {
const src = nodes.find((n) => n.id === pendingConnection.source) const src = nodes.find((n) => n.id === pendingConnection.source)
const tgt = nodes.find((n) => n.id === pendingConnection.target) const tgt = nodes.find((n) => n.id === pendingConnection.target)
const srcType = src?.data.type const srcType = src?.data.type as NodeData['type']
const tgtType = tgt?.data.type const tgtType = tgt?.data.type as NodeData['type']
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') { if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
updateNode(pendingConnection.source, { parent_id: pendingConnection.target }) updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) { } 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') {
updateNode(pendingConnection.target, { parent_id: pendingConnection.source }) updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
} }
} }
@@ -338,6 +344,10 @@ export default function App() {
setEditEdgeId(edge.id) setEditEdgeId(edge.id)
}, []) }, [])
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
handleEditNode(node.id)
}, [handleEditNode])
const handleEdgeUpdate = useCallback((data: EdgeData) => { const handleEdgeUpdate = useCallback((data: EdgeData) => {
if (!editEdgeId) return if (!editEdgeId) return
snapshotHistory() snapshotHistory()
@@ -352,6 +362,13 @@ export default function App() {
setEditEdgeId(null) setEditEdgeId(null)
}, [editEdgeId, deleteEdge, snapshotHistory]) }, [editEdgeId, deleteEdge, snapshotHistory])
const handleClearWaypoints = useCallback(() => {
if (!editEdgeId) return
snapshotHistory()
updateEdge(editEdgeId, { waypoints: [] })
setEditEdgeId(null)
}, [editEdgeId, updateEdge, snapshotHistory])
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
@@ -367,6 +384,8 @@ export default function App() {
onScan={() => setScanConfigOpen(true)} onScan={() => setScanConfigOpen(true)}
onSave={handleSave} onSave={handleSave}
onNodeApproved={setEditNodeId} onNodeApproved={setEditNodeId}
forceView={sidebarForceView}
highlightPendingId={highlightPendingId}
/> />
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<Toolbar <Toolbar
@@ -383,19 +402,35 @@ export default function App() {
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} /> <CanvasContainer
onConnect={handleEdgeConnect}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
/>
</div> </div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />} {(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
</div> </div>
</div> </div>
</div> </div>
<NodeModal <NodeModal
key={addNodeOpen ? 'add-open' : 'add-closed'}
open={addNodeOpen} open={addNodeOpen}
onClose={() => setAddNodeOpen(false)} onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode} onSubmit={handleAddNode}
title="Add Node" title="Add Node"
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} parentContainerNodes={nodes
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/> />
{/* key forces re-mount when editing a different node, resetting form state */} {/* key forces re-mount when editing a different node, resetting form state */}
@@ -406,7 +441,9 @@ export default function App() {
onSubmit={handleUpdateNode} onSubmit={handleUpdateNode}
initial={editNode?.data} initial={editNode?.data}
title="Edit Node" title="Edit Node"
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))} parentContainerNodes={nodes
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/> />
<EdgeModal <EdgeModal
@@ -427,6 +464,7 @@ export default function App() {
onClose={() => setEditEdgeId(null)} onClose={() => setEditEdgeId(null)}
onSubmit={handleEdgeUpdate} onSubmit={handleEdgeUpdate}
onDelete={handleEdgeDelete} onDelete={handleEdgeDelete}
onClearWaypoints={handleClearWaypoints}
initial={editEdge?.data} initial={editEdge?.data}
title="Edit Link" title="Edit Link"
/> />
@@ -435,7 +473,11 @@ export default function App() {
<ScanConfigModal <ScanConfigModal
open={scanConfigOpen} open={scanConfigOpen}
onClose={() => setScanConfigOpen(false)} onClose={() => setScanConfigOpen(false)}
onScanNow={() => toast.success('Scan triggered')} onScanNow={() => {
toast.success('Network scan started — check Scan History for results')
setSidebarForceView(undefined)
setTimeout(() => setSidebarForceView('history'), 0)
}}
/> />
)} )}
@@ -481,9 +523,26 @@ export default function App() {
onClose={() => setThemeModalOpen(false)} onClose={() => setThemeModalOpen(false)}
/> />
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} /> <SearchModal
open={searchOpen}
onClose={() => setSearchOpen(false)}
onOpenPending={(deviceId) => {
setHighlightPendingId(undefined)
setSidebarForceView(undefined)
setTimeout(() => {
setHighlightPendingId(deviceId)
setSidebarForceView('pending')
}, 0)
}}
/>
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> <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" /> <Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider> </ReactFlowProvider>
</TooltipProvider> </TooltipProvider>
+11 -2
View File
@@ -56,9 +56,18 @@ export const scanApi = {
pending: () => api.get('/scan/pending'), pending: () => api.get('/scan/pending'),
hidden: () => api.get('/scan/hidden'), hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'), runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`), hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'), bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data), 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),
}
export const settingsApi = {
get: () => api.get<{ interval_seconds: number }>('/settings'),
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
} }
+14 -4
View File
@@ -18,6 +18,7 @@ import {
BackgroundVariant, BackgroundVariant,
Controls, Controls,
ConnectionMode, ConnectionMode,
useReactFlow,
type Node, type Node,
} from '@xyflow/react' } from '@xyflow/react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
@@ -36,7 +37,8 @@ const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready' type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() { function LiveViewCanvas() {
const { nodes, edges, loadCanvas } = useCanvasStore() const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect): // Derive initial view state synchronously (avoids calling setState inside an effect):
@@ -71,8 +73,8 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>( const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox') .filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
.map((n: ApiNode) => [n.id, n.container_mode !== false]) .map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
) )
loadCanvas( loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
@@ -87,6 +89,15 @@ function LiveViewCanvas() {
}) })
}, [loadCanvas]) }, [loadCanvas])
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
const id = setTimeout(() => {
fitView({ padding: 0.12, duration: 350 })
clearFitViewPending()
}, 50)
return () => clearTimeout(id)
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
const ip = node.data.ip const ip = node.data.ip
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer') if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
@@ -129,7 +140,6 @@ function LiveViewCanvas() {
elementsSelectable={false} elementsSelectable={false}
panOnDrag panOnDrag
zoomOnScroll zoomOnScroll
fitView
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
Controls: () => null, Controls: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -50,6 +52,8 @@ describe('LiveView (non-standalone)', () => {
useCanvasStore.setState({ nodes: [], edges: [] }) useCanvasStore.setState({ nodes: [], edges: [] })
}) })
afterEach(() => { setSearch('') })
// ── No key ──────────────────────────────────────────────────────────────── // ── No key ────────────────────────────────────────────────────────────────
it('shows no-key error when ?key= is missing', async () => { it('shows no-key error when ?key= is missing', async () => {
@@ -133,11 +137,26 @@ describe('LiveView (non-standalone)', () => {
// ── Standalone mode ──────────────────────────────────────────────────────── // ── Standalone mode ────────────────────────────────────────────────────────
const XYFLOW_MOCK = {
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />,
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}
describe('LiveView (standalone — localStorage)', () => { describe('LiveView (standalone — localStorage)', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear() localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [] }) useCanvasStore.setState({ nodes: [], edges: [] })
vi.mocked(liveviewApi.load).mockReset() })
afterEach(() => {
setSearch('')
vi.unstubAllEnvs()
}) })
it('loads canvas from localStorage without calling the API', async () => { it('loads canvas from localStorage without calling the API', async () => {
@@ -151,25 +170,12 @@ describe('LiveView (standalone — localStorage)', () => {
} }
localStorage.setItem('homelable_canvas', JSON.stringify(stored)) localStorage.setItem('homelable_canvas', JSON.stringify(stored))
// Stub VITE_STANDALONE before re-importing
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') // no key needed in standalone
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
vi.unstubAllEnvs()
})
it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true') vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules() vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView') const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('') setSearch('')
@@ -178,8 +184,24 @@ describe('LiveView (standalone — localStorage)', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined() expect(screen.getByTestId('react-flow')).toBeDefined()
}) })
expect(liveviewApi.load).not.toHaveBeenCalled() expect(mockLoad).not.toHaveBeenCalled()
})
vi.unstubAllEnvs() it('shows canvas (empty) when localStorage has no saved data', async () => {
vi.stubEnv('VITE_STANDALONE', 'true')
vi.resetModules()
const mockLoad = vi.fn()
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
const { default: LiveViewStandalone } = await import('../LiveView')
setSearch('')
render(<LiveViewStandalone />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(mockLoad).not.toHaveBeenCalled()
}) })
}) })
@@ -1,40 +1,64 @@
import { useCallback } from 'react' import { useCallback, useEffect, useState } from 'react'
import { import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
ControlButton,
BackgroundVariant, BackgroundVariant,
ConnectionMode, ConnectionMode,
SelectionMode,
useReactFlow,
type Node, type Node,
type Edge, type Edge,
type Connection, type Connection,
} from '@xyflow/react' } from '@xyflow/react'
import { MousePointer2, Hand } from 'lucide-react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes' import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes' import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps { interface CanvasContainerProps {
onConnect?: (connection: Connection) => void onConnect?: (connection: Connection) => void
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void onNodeDragStart?: () => void
onOpenPending?: (deviceId: string) => void
} }
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) { export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, nodes, edges,
onNodesChange, onEdgesChange, onNodesChange, onEdgesChange,
setSelectedNode, setSelectedNode, snapshotHistory,
fitViewPending, clearFitViewPending,
} = useCanvasStore() } = useCanvasStore()
const { fitView } = useReactFlow()
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
const id = setTimeout(() => {
fitView({ padding: 0.12, duration: 350 })
clearFitViewPending()
}, 50)
return () => clearTimeout(id)
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
setSelectedNode(node.id) if (e.ctrlKey || e.metaKey) {
setSelectedNode(null)
} else {
setSelectedNode(node.id)
}
}, [setSelectedNode]) }, [setSelectedNode])
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
@@ -45,6 +69,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onEdgeDoubleClick?.(edge) onEdgeDoubleClick?.(edge)
}, [onEdgeDoubleClick]) }, [onEdgeDoubleClick])
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
onNodeDoubleClick?.(node)
}, [onNodeDoubleClick])
const handleBeforeDelete = useCallback(async () => {
snapshotHistory()
return true
}, [snapshotHistory])
const isValidConnection = useCallback(
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
[]
)
return ( return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}> <div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow <ReactFlow
@@ -56,24 +94,41 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onPaneClick={onPaneClick} onPaneClick={onPaneClick}
onEdgeDoubleClick={handleEdgeDoubleClick} onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart} onNodeDragStart={onNodeDragStart}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}
onBeforeDelete={handleBeforeDelete}
selectionOnDrag={lassoMode}
panOnDrag={lassoMode ? [1, 2] : true}
panActivationKeyCode="Space"
selectionMode={SelectionMode.Partial}
multiSelectionKeyCode={['Meta', 'Control']}
minZoom={0.25}
maxZoom={2.5}
snapToGrid snapToGrid
snapGrid={[16, 16]} snapGrid={[8, 8]}
fitView
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false} elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
isValidConnection={(connection) => connection.source !== connection.target} isValidConnection={isValidConnection}
> >
<Background <Background
variant={BackgroundVariant.Dots} variant={BackgroundVariant.Dots}
gap={24} gap={16}
size={1} size={1}
color={theme.colors.canvasDotColor} color={theme.colors.canvasDotColor}
/> />
<Controls /> <SearchBar onOpenPending={onOpenPending} />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
title={lassoMode ? 'Switch to pan mode (Space to pan)' : 'Switch to lasso mode'}
>
{lassoMode ? <MousePointer2 size={12} /> : <Hand size={12} />}
</ControlButton>
</Controls>
</ReactFlow> </ReactFlow>
</div> </div>
) )
@@ -0,0 +1,218 @@
import { useState, useEffect, useRef } from 'react'
import { useReactFlow } from '@xyflow/react'
import { Search, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import { NODE_TYPE_LABELS } from '@/types'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
interface SearchBarProps {
onOpenPending?: (deviceId: string) => void
}
export function SearchBar({ onOpenPending }: SearchBarProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const inputRef = useRef<HTMLInputElement>(null)
const { nodes, setSelectedNode } = useCanvasStore()
const { setCenter } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
}, [open])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault()
setOpen(true)
}
if (e.key === 'Escape') {
setOpen(false)
setQuery('')
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
useEffect(() => {
if (open) inputRef.current?.focus()
}, [open])
const q = query.toLowerCase().trim()
const nodeResults = q
? nodes.filter((n) => {
if (n.data.type === 'groupRect') return false
return (
n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q) ||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
)
})
: []
const pendingResults = q
? pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
).slice(0, 4)
: []
const totalResults = nodeResults.length + pendingResults.length
const goToNode = (id: string) => {
const node = nodes.find((n) => n.id === id)
if (!node) return
setSelectedNode(id)
// For grouped nodes, add parent's absolute position
let absX = node.position.x
let absY = node.position.y
if (node.parentId) {
const parent = nodes.find((n) => n.id === node.parentId)
if (parent) { absX += parent.position.x; absY += parent.position.y }
}
const w = node.measured?.width ?? node.width ?? 200
const h = node.measured?.height ?? node.height ?? 80
setCenter(absX + w / 2, absY + h / 2, { zoom: 1.5, duration: 500 })
setOpen(false)
setQuery('')
}
if (!open) return null
return (
<div
className="nodrag nowheel"
style={{
position: 'absolute',
top: 16,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
width: 360,
pointerEvents: 'all',
}}
>
<div style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.6)',
overflow: 'hidden',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px' }}>
<Search size={14} style={{ color: '#8b949e', flexShrink: 0 }} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, IP, hostname or service…"
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e6edf3',
fontSize: 13,
}}
/>
{query && (
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
{totalResults} result{totalResults !== 1 ? 's' : ''}
</span>
)}
<button
onClick={() => { setOpen(false); setQuery('') }}
aria-label="Close search"
style={{ color: '#8b949e', background: 'none', border: 'none', cursor: 'pointer', padding: 2 }}
>
<X size={14} />
</button>
</div>
{totalResults > 0 && (
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
{nodeResults.map((n) => (
<button
key={n.id}
onClick={() => goToNode(n.id)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.data.label}
</span>
{n.data.ip && (
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{n.data.ip}
</span>
)}
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
</span>
</button>
))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
)}
{pendingResults.map((d) => {
const serviceName = d.services.find((s) => s.service_name)?.service_name
return (
<button
key={d.id}
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
background: 'none',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
>
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.hostname ?? d.ip}
</span>
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{serviceName ?? d.ip}
</span>
</button>
)
})}
</div>
)}
{q && totalResults === 0 && (
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
No results for &ldquo;{query}&rdquo;
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Server } from 'lucide-react'
import { BaseNode } from '../nodes/BaseNode'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
let mockZoom = 1
vi.mock('@xyflow/react', () => ({
Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' },
NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ zoom: mockZoom }),
}))
vi.mock('@/stores/themeStore', () => ({
useThemeStore: (sel: (s: { activeTheme: string }) => unknown) => sel({ activeTheme: 'dark' }),
}))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
}))
vi.mock('@/utils/themes', () => ({
THEMES: {
dark: {
colors: {
statusColors: { online: '#39d353', offline: '#f85149', pending: '#e3b341', unknown: '#8b949e' },
nodeSubtextColor: '#8b949e',
nodeLabelColor: '#e6edf3',
nodeIconBackground: '#21262d',
handleBackground: '#30363d',
handleBorder: '#30363d',
},
},
},
}))
vi.mock('@/utils/nodeColors', () => ({
resolveNodeColors: () => ({ background: '#161b22', border: '#30363d', icon: '#00d4ff' }),
}))
vi.mock('@/utils/nodeIcons', () => ({
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
}))
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', () => ({
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
}))
vi.mock('@/utils/handleUtils', () => ({
BOTTOM_HANDLE_IDS: ['bottom'],
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
}))
beforeEach(() => { mockZoom = 1 })
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: data.type ?? 'server',
position: { x: 0, y: 0 },
data: {
label: 'Test Node',
type: 'server',
status: 'online',
services: [],
...data,
},
}
}
function renderBaseNode(data: Partial<NodeData>) {
const node = makeNode(data)
return render(
<BaseNode
id={node.id}
data={node.data}
selected={false}
icon={Server}
type="server"
dragging={false}
zIndex={0}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
)
}
describe('BaseNode — borderWidth zoom scaling', () => {
beforeEach(() => { mockZoom = 1 })
it('borderWidth is 1px at zoom=1', () => {
mockZoom = 1
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
})
it('borderWidth scales to 2px at zoom=0.5', () => {
mockZoom = 0.5
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('2px')
})
it('borderWidth is clamped to 1px at zoom=2', () => {
mockZoom = 2
const { container } = renderBaseNode({})
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
})
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
mockZoom = 0.5
const node = makeNode({ status: 'online' })
const { container } = render(
<BaseNode id={node.id} data={node.data} selected={true} icon={Server}
type="server" dragging={false} zIndex={0} isConnectable={true}
positionAbsoluteX={0} positionAbsoluteY={0} />
)
expect((container.firstChild as HTMLElement).style.boxShadow).toContain('0 0 0 2px')
})
})
describe('BaseNode — properties rendering', () => {
it('renders visible properties on the node', () => {
renderBaseNode({
properties: [
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true },
],
})
expect(screen.getByText('CPU Model')).toBeDefined()
// Value is rendered with a middle-dot prefix: "· 32 GB"
expect(screen.getByText(/32 GB/)).toBeDefined()
})
it('does not render properties with visible=false', () => {
renderBaseNode({
properties: [
{ key: 'Secret', value: 'hidden', icon: null, visible: false },
],
})
expect(screen.queryByText('Secret')).toBeNull()
})
it('renders nothing when properties array is empty', () => {
const { container } = renderBaseNode({ properties: [] })
// No properties section — only the main node card
expect(container.querySelectorAll('.flex.flex-col.gap-1').length).toBe(0)
})
it('renders label and ip regardless of properties', () => {
renderBaseNode({
label: 'My Server',
ip: '192.168.1.10',
properties: [{ key: 'OS', value: 'Debian 12', icon: 'Server', visible: true }],
})
expect(screen.getByText('My Server')).toBeDefined()
expect(screen.getByText('192.168.1.10')).toBeDefined()
expect(screen.getByText('OS')).toBeDefined()
})
})
describe('BaseNode — legacy hardware fallback', () => {
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
renderBaseNode({
properties: undefined,
show_hardware: true,
cpu_model: 'Intel Xeon E5-2680',
ram_gb: 32,
})
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
})
it('does not render legacy hardware when properties array is present (even if empty)', () => {
renderBaseNode({
properties: [],
show_hardware: true,
cpu_model: 'Intel Xeon E5-2680',
})
// properties array exists → new system, legacy section skipped
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
})
it('does not render legacy hardware when show_hardware is false', () => {
renderBaseNode({
properties: undefined,
show_hardware: false,
cpu_model: 'Intel Xeon E5-2680',
})
expect(screen.queryByText('Intel Xeon E5-2680')).toBeNull()
})
})
@@ -16,8 +16,12 @@ vi.mock('@xyflow/react', () => ({
}, },
Background: () => null, Background: () => null,
Controls: () => null, Controls: () => null,
ControlButton: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' }, ConnectionMode: { Loose: 'loose' },
SelectionMode: { Partial: 'partial' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
})) }))
vi.mock('@xyflow/react/dist/style.css', () => ({})) vi.mock('@xyflow/react/dist/style.css', () => ({}))
@@ -101,6 +105,24 @@ describe('CanvasContainer', () => {
}).not.toThrow() }).not.toThrow()
}) })
// ── Node double-click ─────────────────────────────────────────────────────
it('calls onNodeDoubleClick prop when a node is double-clicked', () => {
const onNodeDoubleClick = vi.fn()
const node = makeNode('n1')
render(<CanvasContainer onNodeDoubleClick={onNodeDoubleClick} />)
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
expect(onNodeDoubleClick).toHaveBeenCalledWith(node)
})
it('does not throw when onNodeDoubleClick is not provided', () => {
const node = makeNode('n1')
render(<CanvasContainer />)
expect(() => {
;(rfProps.onNodeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
}).not.toThrow()
})
// ── Connection validation ───────────────────────────────────────────────── // ── Connection validation ─────────────────────────────────────────────────
it('isValidConnection returns false for self-connections', () => { it('isValidConnection returns false for self-connections', () => {
@@ -139,8 +161,75 @@ describe('CanvasContainer', () => {
expect(rfProps.snapToGrid).toBe(true) expect(rfProps.snapToGrid).toBe(true)
}) })
it('sets snapGrid to [16, 16]', () => { it('sets snapGrid to [8, 8]', () => {
render(<CanvasContainer />) render(<CanvasContainer />)
expect(rfProps.snapGrid).toEqual([16, 16]) expect(rfProps.snapGrid).toEqual([8, 8])
})
// ── Delete key ────────────────────────────────────────────────────────────
it('sets deleteKeyCode to include both Backspace and Delete', () => {
render(<CanvasContainer />)
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
})
// ── Lasso / multi-select ──────────────────────────────────────────────────
it('enables selectionOnDrag for lasso selection', () => {
render(<CanvasContainer />)
expect(rfProps.selectionOnDrag).toBe(true)
})
it('sets panActivationKeyCode to Space', () => {
render(<CanvasContainer />)
expect(rfProps.panActivationKeyCode).toBe('Space')
})
it('sets panOnDrag to [1, 2]', () => {
render(<CanvasContainer />)
expect(rfProps.panOnDrag).toEqual([1, 2])
})
it('sets selectionMode to Partial', () => {
render(<CanvasContainer />)
expect(rfProps.selectionMode).toBe('partial')
})
it('sets multiSelectionKeyCode to Meta and Control', () => {
render(<CanvasContainer />)
expect(rfProps.multiSelectionKeyCode).toEqual(['Meta', 'Control'])
})
it('clears selectedNode (sets null) on Ctrl+click instead of selecting', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
{ ctrlKey: true, metaKey: false } as unknown as MouseEvent,
node,
)
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
it('clears selectedNode (sets null) on Cmd+click', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node], selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)(
{ ctrlKey: false, metaKey: true } as unknown as MouseEvent,
node,
)
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
const snapshotHistory = vi.fn()
useCanvasStore.setState({ snapshotHistory } as unknown as Parameters<typeof useCanvasStore.setState>[0])
render(<CanvasContainer />)
const result = await (rfProps.onBeforeDelete as () => Promise<boolean>)()
expect(snapshotHistory).toHaveBeenCalledOnce()
expect(result).toBe(true)
}) })
}) })
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '@testing-library/react'
import { GroupNode } from '../nodes/GroupNode'
import * as canvasStore from '@/stores/canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
vi.mock('@/stores/canvasStore')
vi.mock('@xyflow/react', () => ({
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
<div data-testid="node-resizer" data-visible={isVisible} />
),
useReactFlow: () => ({}),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
function makeGroupNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id: 'g1',
type: 'group',
position: { x: 0, y: 0 },
width: 400,
height: 250,
data: {
label: 'My Group',
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
...overrides,
},
}
}
function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, storeNodes: unknown[] = []) {
const node = makeGroupNode(props.data)
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: storeNodes,
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<GroupNode
id="g1"
data={node.data}
selected={false}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
{...props}
/>,
)
}
describe('GroupNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders the group label when show_border is true', () => {
renderGroupNode()
expect(screen.getByText('My Group')).toBeDefined()
})
it('hides the header when show_border is false and not selected', () => {
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: false })
expect(screen.queryByText('My Group')).toBeNull()
})
it('shows header when show_border is false but node is selected', () => {
renderGroupNode({ data: makeGroupNode({ custom_colors: { show_border: false } }).data, selected: true })
expect(screen.getByText('My Group')).toBeDefined()
})
it('shows NodeResizer only when selected', () => {
const { rerender } = renderGroupNode({ selected: false })
expect(screen.getByTestId('node-resizer').getAttribute('data-visible')).toBe('false')
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
rerender(
<GroupNode
id="g1"
data={makeGroupNode().data}
selected={true}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>,
)
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' } },
{ id: 'c2', parentId: 'g1', data: { status: 'offline' } },
{ id: 'c3', parentId: 'other', data: { status: 'online' } }, // different group — excluded
]
renderGroupNode({}, storeNodes)
// Two status indicators: one online, one offline (c3 excluded — wrong parent)
const statusSpans = screen.getAllByText(/● \d+/)
expect(statusSpans).toHaveLength(2)
})
it('does not show status summary when group has no children', () => {
renderGroupNode()
expect(screen.queryByText(/●/)).toBeNull()
})
})
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { GroupRectNode } from '../nodes/GroupRectNode'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@xyflow/react', () => ({
Handle: ({ id, type }: { id: string; type: string }) => <div data-testid={`handle-${id}`} data-type={type} />,
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
NodeResizer: () => null,
}))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: (sel: (s: { setEditingGroupRectId: () => void }) => unknown) =>
sel({ setEditingGroupRectId: vi.fn() }),
}))
function makeNode(overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id: 'zone1',
type: 'groupRect',
position: { x: 0, y: 0 },
data: { label: 'My Zone', type: 'groupRect', status: 'unknown', services: [], ...overrides },
}
}
function renderZone(overrides: Partial<NodeData> = {}) {
const node = makeNode(overrides)
return render(
<GroupRectNode
id={node.id}
data={node.data}
selected={false}
type="groupRect"
dragging={false}
zIndex={0}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
)
}
describe('GroupRectNode — handles', () => {
it('renders source handles on all four sides', () => {
renderZone()
expect(screen.getByTestId('handle-zone-top')).toBeDefined()
expect(screen.getByTestId('handle-zone-right')).toBeDefined()
expect(screen.getByTestId('handle-zone-bottom')).toBeDefined()
expect(screen.getByTestId('handle-zone-left')).toBeDefined()
})
it('renders target handles on all four sides', () => {
renderZone()
expect(screen.getByTestId('handle-zone-top-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-right-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-bottom-t')).toBeDefined()
expect(screen.getByTestId('handle-zone-left-t')).toBeDefined()
})
it('renders 8 handles total (4 source + 4 target)', () => {
renderZone()
expect(screen.getAllByTestId(/^handle-zone-/).length).toBe(8)
})
})
describe('GroupRectNode — label', () => {
it('renders inside label by default', () => {
renderZone({ label: 'DMZ' })
expect(screen.getByText('DMZ')).toBeDefined()
})
it('renders no label when label is empty', () => {
renderZone({ label: '' })
expect(screen.queryByText('DMZ')).toBeNull()
})
})
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SearchBar } from '../SearchBar'
import * as canvasStore from '@/stores/canvasStore'
vi.mock('@/stores/canvasStore')
vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ setCenter: vi.fn() }),
}))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [], ip: null, hostname: null },
...overrides,
}
}
function setupStore(nodes: unknown[] = []) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes,
setSelectedNode: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function openSearch() {
fireEvent.keyDown(window, { key: 'f', ctrlKey: true })
}
describe('SearchBar', () => {
beforeEach(() => {
setupStore([])
vi.clearAllMocks()
})
it('is hidden by default', () => {
render(<SearchBar />)
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('opens on Ctrl+F', () => {
render(<SearchBar />)
openSearch()
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
})
it('opens on Cmd+F', () => {
render(<SearchBar />)
fireEvent.keyDown(window, { key: 'f', metaKey: true })
expect(screen.getByPlaceholderText(/search/i)).toBeDefined()
})
it('closes on Escape', () => {
render(<SearchBar />)
openSearch()
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('closes when X button is clicked', () => {
render(<SearchBar />)
openSearch()
fireEvent.click(screen.getByLabelText('Close search'))
expect(screen.queryByPlaceholderText(/search/i)).toBeNull()
})
it('filters by label', () => {
setupStore([
makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'online', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('My NAS')).toBeNull()
})
it('filters by IP', () => {
setupStore([
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: '192.168.1.10', hostname: null } }),
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: '10.0.0.1', hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: '192.168' } })
expect(screen.getByText('Server A')).toBeDefined()
expect(screen.queryByText('Server B')).toBeNull()
})
it('filters by service name', () => {
setupStore([
makeNode('n1', { data: { label: 'Web Server', type: 'server', status: 'online', services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'DB Server', type: 'server', status: 'online', services: [{ service_name: 'mysql', port: 3306, protocol: 'tcp' }], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'nginx' } })
expect(screen.getByText('Web Server')).toBeDefined()
expect(screen.queryByText('DB Server')).toBeNull()
})
it('excludes groupRect nodes from results', () => {
setupStore([
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'dmz' } })
expect(screen.queryByText('DMZ Zone')).toBeNull()
})
it('shows no-results message when query has no matches', () => {
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzznomatch' } })
expect(screen.getByText(/no results/i)).toBeDefined()
})
it('calls setSelectedNode when a result is clicked', () => {
const setSelectedNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode('n1', { data: { label: 'My Server', type: 'server', status: 'online', services: [], ip: null, hostname: null } })],
setSelectedNode,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'my server' } })
fireEvent.click(screen.getByText('My Server'))
expect(setSelectedNode).toHaveBeenCalledWith('n1')
})
it('shows result count', () => {
setupStore([
makeNode('n1', { data: { label: 'Alpha', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
makeNode('n2', { data: { label: 'Beta', type: 'server', status: 'online', services: [], ip: null, hostname: null } }),
])
render(<SearchBar />)
openSearch()
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'a' } })
expect(screen.getByText(/2 results/i)).toBeDefined()
})
})
@@ -0,0 +1,175 @@
import { describe, it, expect } from 'vitest'
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
describe('buildWaypointPath — bezier (default)', () => {
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
// With only 2 pts (src + target), catmull-rom = cubic bezier
const path = buildWaypointPath(0, 0, [], 100, 100)
expect(path).toMatch(/^M 0 0 C/)
})
it('routes through a single waypoint with smooth curve', () => {
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100)
expect(path).toMatch(/^M 0 0 C/)
// Should not be a straight polyline
expect(path).not.toContain(' L ')
})
it('routes through multiple waypoints', () => {
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100)
expect(path).toMatch(/^M 0 0 C/)
})
})
describe('buildWaypointPath — smooth style', () => {
it('builds a direct straight line with no waypoints (no bend)', () => {
// Only 2 points → no intermediate vertex → no rounding needed
expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100')
})
it('routes through a single waypoint with straight lines (no intermediate bend)', () => {
// 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth')
// Should start at source and end at target
expect(path).toMatch(/^M 0 0/)
expect(path).toMatch(/100 100$/)
// Should contain a quadratic bezier at the waypoint corner
expect(path).toContain('Q')
})
it('routes through multiple waypoints with rounded corners', () => {
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
expect(path).toMatch(/^M 0 0/)
expect(path).toMatch(/100 100$/)
expect(path).toContain('Q')
})
it('does not round corners when segment is too short (r clamped to 0)', () => {
// Adjacent waypoints very close together — r → 0, falls back to L
const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth')
expect(path).toMatch(/^M 0 0/)
})
})
describe('snap45', () => {
// Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px)
it('snaps horizontal direction when close (deviation < threshold)', () => {
// (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps
const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 })
expect(r.y).toBe(0)
expect(r.x).toBeGreaterThan(0)
})
it('snaps vertical direction when close', () => {
const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 })
expect(r.x).toBe(0)
expect(r.y).toBeGreaterThan(0)
})
it('snaps 45° diagonal when close', () => {
// (80, 83) — nearly 45°, deviation ≈ 2px → snaps
const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 })
expect(r.x).toBe(r.y)
})
it('does NOT snap when deviation exceeds threshold', () => {
// (100, 40) — deviation from 0° is ~40px > 15 → no snap
const pos = { x: 100, y: 40 }
const r = snap45({ x: 0, y: 0 }, pos)
expect(r).toEqual(pos)
})
it('returns pos unchanged when distance < 1', () => {
const pos = { x: 5, y: 5 }
expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos)
})
it('preserves distance from origin when snapping', () => {
const from = { x: 0, y: 0 }
const pos = { x: 100, y: 3 } // close to horizontal
const r = snap45(from, pos)
const origDist = Math.hypot(pos.x - from.x, pos.y - from.y)
const snapDist = Math.hypot(r.x - from.x, r.y - from.y)
expect(snapDist).toBeCloseTo(origDist, 0)
})
})
describe('snap45both', () => {
it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => {
// prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both
const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 })
// Result must be on a 45°-ray from (0,0)
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
// Result must be on a 45°-ray from (100,100)
const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4)
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
})
it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => {
// prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each)
// pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100)
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 })
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4)
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
})
it('returns raw pos when beyond threshold', () => {
// pos=(100,80) is 20px from nearest intersection (100,100) → no snap
const pos = { x: 100, y: 80 }
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos)
expect(r).toEqual(pos)
})
it('falls back gracefully when prev === next', () => {
// No valid intersection → fallback to snap45
const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 })
expect(r).toBeDefined()
})
})
describe('distToSegment', () => {
it('returns 0 when point is on the segment', () => {
expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0)
})
it('returns perpendicular distance when point is beside segment', () => {
expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10)
})
it('returns distance to nearest endpoint when point is past the segment', () => {
expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100)
})
it('handles zero-length segment (a === b)', () => {
expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5)
})
})
describe('findInsertIndex', () => {
it('returns 0 when there are no waypoints (only one segment)', () => {
expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0)
})
it('inserts before first waypoint when click is on first segment', () => {
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 })
expect(idx).toBe(0)
})
it('inserts after first waypoint when click is on second segment', () => {
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 })
expect(idx).toBe(1)
})
it('picks the closest segment among multiple', () => {
const idx = findInsertIndex(
0, 0,
[{ x: 100, y: 0 }, { x: 100, y: 100 }],
200, 100,
{ x: 150, y: 105 },
)
expect(idx).toBe(2)
})
})
+254 -12
View File
@@ -1,15 +1,19 @@
import { useCallback } from 'react'
import { import {
BaseEdge, BaseEdge,
EdgeLabelRenderer, EdgeLabelRenderer,
getBezierPath, getBezierPath,
getSmoothStepPath, getSmoothStepPath,
useReactFlow,
useStore, useStore,
type EdgeProps, type EdgeProps,
type Edge, type Edge,
} from '@xyflow/react' } from '@xyflow/react'
import type { EdgeData, EdgeType } from '@/types' import type { EdgeData, EdgeType, Waypoint } from '@/types'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string {
return VLAN_COLORS[vlanId % VLAN_COLORS.length] return VLAN_COLORS[vlanId % VLAN_COLORS.length]
} }
// ── Waypoint drag handle ─────────────────────────────────────────────────────
interface WaypointHandleProps {
edgeId: string
index: number
waypoint: Waypoint
waypoints: Waypoint[]
color: string
pathStyle?: string
prevPoint: Waypoint
nextPoint: Waypoint
}
function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) {
const { screenToFlowPosition } = useReactFlow()
const updateEdge = useCanvasStore((s) => s.updateEdge)
const handlePointerDown = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (e.buttons !== 1) return
let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY })
if (pathStyle === 'smooth') {
// Find the intersection of 45°-rays from both adjacent points so that
// ALL segments (prev→this and this→next) snap to 45° simultaneously.
pos = snap45both(prevPoint, nextPoint, pos)
}
const next = [...waypoints]
next[index] = pos
updateEdge(edgeId, { waypoints: next })
}, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint])
const handlePointerUp = useCallback((e: React.PointerEvent) => {
e.currentTarget.releasePointerCapture(e.pointerId)
}, [])
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) })
}, [edgeId, waypoints, index, updateEdge])
return (
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
width: 10,
height: 10,
borderRadius: '50%',
background: color,
border: '2px solid #0d1117',
cursor: 'grab',
pointerEvents: 'all',
zIndex: 10,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onDoubleClick={handleDoubleClick}
title="Drag to move · Double-click to remove"
/>
)
}
// ── Add waypoint handle (+ button at segment midpoints) ──────────────────────
interface AddWaypointHandleProps {
edgeId: string
insertIndex: number
x: number
y: number
waypoints: Waypoint[]
color: string
pathStyle?: string
prevPoint: Waypoint
}
function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) {
const updateEdge = useCanvasStore((s) => s.updateEdge)
const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
let pos = { x, y }
if (pathStyle === 'smooth') pos = snap45(prevPoint, pos)
const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)]
updateEdge(edgeId, { waypoints: next })
}, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint])
return (
<div
onClick={handleClick}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
width: 14,
height: 14,
borderRadius: '50%',
background: '#0d1117',
border: `1.5px solid ${color}`,
color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
lineHeight: 1,
cursor: 'crosshair',
pointerEvents: 'all',
zIndex: 9,
opacity: 0.7,
}}
title="Click to add waypoint"
>
+
</div>
)
}
// ── Segment midpoints ────────────────────────────────────────────────────────
/**
* Compute + handle positions for each path segment.
* For smooth style: bias the first + handle to the source handle axis and the
* last + handle to the target handle axis, so clicking always gives a clean
* perpendicular exit/entry (no diagonal guesswork near the nodes).
*/
function segmentMidpoints(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
pathStyle?: string,
sourcePosition?: string,
): { x: number; y: number; insertIndex: number }[] {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
const isSmooth = pathStyle === 'smooth'
return pts.slice(0, -1).map((a, i) => {
const b = pts[i + 1]
let mx = (a.x + b.x) / 2
const my = (a.y + b.y) / 2
// For smooth style with no existing waypoints, bias the single + handle onto
// the source handle axis so clicking it creates a perpendicular exit.
// Only applies to bottom/top handles (vertical exits) and only when the edge
// has no waypoints yet — once waypoints exist, all + handles stay at the
// real segment midpoint so they remain visually on the edge.
if (isSmooth && i === 0 && pts.length === 2) {
const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top'
if (vertSrc) mx = a.x // same X as source → + sits directly below/above node
}
return { x: mx, y: my, insertIndex: i }
})
}
// ── Main edge component ──────────────────────────────────────────────────────
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) { export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
@@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
const targetType = useStore((s) => s.nodeLookup.get(target)?.type) const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0
? data.waypoints as Waypoint[]
: []
const hasWaypoints = waypoints.length > 0
const pathStyle = data?.path_style as string | undefined
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
const [edgePath, labelX, labelY] = data?.path_style === 'smooth' const [autoPath, labelX] = pathStyle === 'smooth'
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
: getBezierPath(pathArgs) : getBezierPath(pathArgs)
const edgePath = hasWaypoints
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: autoPath
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
const midY = (sourceY + targetY) / 2
const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors const edgeColors = theme.colors.edgeColors
@@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
} }
const customColor = data?.custom_color as string | undefined const customColor = data?.custom_color as string | undefined
const strokeColor: string = selected
? theme.colors.edgeSelectedColor
: customColor
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
const style: React.CSSProperties = { const style: React.CSSProperties = {
...BASE_STYLES[edgeType], ...BASE_STYLES[edgeType],
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
@@ -50,16 +233,36 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
} }
// Normalize animated value — supports legacy boolean (true → 'snake') const animMode: 'none' | 'snake' | 'flow' | 'basic' =
const animMode: 'none' | 'snake' | 'flow' =
data?.animated === true || data?.animated === 'snake' ? 'snake' : data?.animated === true || data?.animated === 'snake' ? 'snake' :
data?.animated === 'flow' ? 'flow' : 'none' data?.animated === 'flow' ? 'flow' :
data?.animated === 'basic' ? 'basic' : 'none'
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
const midpoints = selected
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
: []
return ( return (
<> <>
<BaseEdge id={id} path={edgePath} style={style} /> <BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
{animMode === 'basic' && (
<path
d={edgePath}
fill="none"
stroke={strokeColor}
strokeWidth={style.strokeWidth as number ?? 2}
strokeDasharray="5"
style={{
pointerEvents: 'none',
animation: 'homelable-basic-dash 0.5s linear infinite',
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
}}
/>
)}
{animMode === 'snake' && ( {animMode === 'snake' && (
<path <path
d={edgePath} d={edgePath}
@@ -92,12 +295,12 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
</path> </path>
)} )}
{data?.label && ( <EdgeLabelRenderer>
<EdgeLabelRenderer> {data?.label && (
<div <div
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
background: theme.colors.edgeLabelBackground, background: theme.colors.edgeLabelBackground,
color: theme.colors.edgeLabelColor, color: theme.colors.edgeLabelColor,
border: `1px solid ${theme.colors.edgeLabelBorder}`, border: `1px solid ${theme.colors.edgeLabelBorder}`,
@@ -105,8 +308,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
> >
{data.label as string} {data.label as string}
</div> </div>
</EdgeLabelRenderer> )}
)}
{/* Existing waypoint drag handles */}
{selected && waypoints.map((wp, idx) => {
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1]
return (
<WaypointHandle
key={`wp-${idx}`}
edgeId={id}
index={idx}
waypoint={wp}
waypoints={waypoints}
color={strokeColor}
pathStyle={pathStyle}
prevPoint={prevPoint}
nextPoint={nextPoint}
/>
)
})}
{/* + handles at segment midpoints to add new waypoints */}
{selected && midpoints.map((mp) => {
const prevPoint = mp.insertIndex === 0
? { x: sourceX, y: sourceY }
: waypoints[mp.insertIndex - 1]
return (
<AddWaypointHandle
key={`add-${mp.insertIndex}`}
edgeId={id}
insertIndex={mp.insertIndex}
x={mp.x}
y={mp.y}
waypoints={waypoints}
color={strokeColor}
pathStyle={pathStyle}
prevPoint={prevPoint}
/>
)
})}
</EdgeLabelRenderer>
</> </>
) )
} }
@@ -0,0 +1,167 @@
import type { Waypoint } from '@/types'
// ── Path builders ─────────────────────────────────────────────────────────────
/** Catmull-Rom → cubic bezier for smooth curves through waypoints */
function buildCatmullRomPath(pts: Waypoint[]): string {
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(i - 1, 0)]
const p1 = pts[i]
const p2 = pts[i + 1]
const p3 = pts[Math.min(i + 2, pts.length - 1)]
const cp1x = p1.x + (p2.x - p0.x) / 6
const cp1y = p1.y + (p2.y - p0.y) / 6
const cp2x = p2.x - (p3.x - p1.x) / 6
const cp2y = p2.y - (p3.y - p1.y) / 6
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`
}
return d
}
/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */
function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string {
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`
let d = `M ${pts[0].x} ${pts[0].y}`
for (let i = 1; i < pts.length - 1; i++) {
const prev = pts[i - 1]
const curr = pts[i]
const next = pts[i + 1]
const dx1 = curr.x - prev.x
const dy1 = curr.y - prev.y
const len1 = Math.hypot(dx1, dy1)
const dx2 = next.x - curr.x
const dy2 = next.y - curr.y
const len2 = Math.hypot(dx2, dy2)
if (len1 < 1 || len2 < 1) {
d += ` L ${curr.x} ${curr.y}`
continue
}
const r = Math.min(radius, len1 / 2, len2 / 2)
// Approach point (on segment prev→curr, r units before corner)
const bx = curr.x - (dx1 / len1) * r
const by = curr.y - (dy1 / len1) * r
// Departure point (on segment curr→next, r units after corner)
const ax = curr.x + (dx2 / len2) * r
const ay = curr.y + (dy2 / len2) * r
d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}`
}
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`
return d
}
export function buildWaypointPath(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
pathStyle: string = 'bezier',
): string {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
}
// ── 45° snapping ──────────────────────────────────────────────────────────────
/**
* Snap `pos` to the nearest 45°-multiple direction from `from`.
* Only snaps when within SNAP_THRESHOLD px of a 45° position.
*/
export function snap45(from: Waypoint, pos: Waypoint): Waypoint {
const dx = pos.x - from.x
const dy = pos.y - from.y
const dist = Math.hypot(dx, dy)
if (dist < 1) return pos
const angle = Math.atan2(dy, dx)
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
const candidate = {
x: Math.round(from.x + dist * Math.cos(snapped)),
y: Math.round(from.y + dist * Math.sin(snapped)),
}
const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y)
return deviation <= SNAP_THRESHOLD ? candidate : pos
}
/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */
const SNAP_THRESHOLD = 15
/**
* Find the position closest to `pos` that lies simultaneously on a 45°-ray
* from `prev` AND on a 45°-ray from `next`.
*
* Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px —
* outside that zone the raw drag position is returned, allowing free placement.
*/
export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint {
let best: Waypoint | null = null
let bestDist = Infinity
for (let i = 0; i < 8; i++) {
const a1 = i * Math.PI / 4
const c1 = Math.cos(a1), s1 = Math.sin(a1)
for (let j = 0; j < 8; j++) {
const a2 = j * Math.PI / 4
const c2 = Math.cos(a2), s2 = Math.sin(a2)
const dx = next.x - prev.x
const dy = next.y - prev.y
const det = -c1 * s2 + c2 * s1
if (Math.abs(det) < 1e-6) continue
const t = (-dx * s2 + c2 * dy) / det
const s = (c1 * dy - s1 * dx) / det
if (t < -1e-6 || s < -1e-6) continue
const ix = prev.x + t * c1
const iy = prev.y + t * s1
const d = Math.hypot(ix - pos.x, iy - pos.y)
if (d < bestDist) {
bestDist = d
best = { x: Math.round(ix), y: Math.round(iy) }
}
}
}
// Only snap if close enough — otherwise let the waypoint move freely
if (best === null || bestDist > SNAP_THRESHOLD) return pos
return best
}
// ── Geometry helpers ──────────────────────────────────────────────────────────
export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number {
const dx = b.x - a.x
const dy = b.y - a.y
const lenSq = dx * dx + dy * dy
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
}
export function findInsertIndex(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
point: Waypoint,
): number {
const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
let minDist = Infinity
let best = 0
for (let i = 0; i < allPts.length - 1; i++) {
const d = distToSegment(point, allPts[i], allPts[i + 1])
if (d < minDist) { minDist = d; best = i }
}
return best
}
@@ -1,13 +1,15 @@
import { createElement } from 'react' import { createElement, useEffect, useMemo } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons' import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp } from '@/utils/maskIp' import { maskIp, splitIps } from '@/utils/maskIp'
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
interface BaseNodeProps extends NodeProps<Node<NodeData>> { interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon icon: LucideIcon
@@ -18,7 +20,13 @@ function formatStorage(gb: number): string {
return `${gb} GB` return `${gb} GB`
} }
export function BaseNode({ data, selected, icon: typeIcon, width, height }: BaseNodeProps) { export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
const { zoom } = useViewport()
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
@@ -27,19 +35,25 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
const colors = resolveNodeColors(data, activeTheme) const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
const showLegacyHardware = !data.properties && data.show_hardware &&
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
return ( return (
<div <div
className="relative flex flex-col rounded-lg border transition-all duration-200" className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
style={{ style={{
background: colors.background, background: colors.background,
borderColor: colors.border, borderColor: colors.border,
borderWidth: selected ? 2 : 1, borderWidth,
boxShadow: isOnline boxShadow: isOnline && selected
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: isOnline
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a` ? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
: selected : selected
? `0 0 8px ${colors.border}44` ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
: 'none', : 'none',
opacity: data.status === 'offline' ? 0.55 : 1, opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140, minWidth: 140,
@@ -51,7 +65,7 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
isVisible={selected} isVisible={selected}
minWidth={140} minWidth={140}
minHeight={50} minHeight={50}
lineStyle={{ borderColor: colors.border, borderWidth: 1 }} lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }} handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
/> />
<Handle <Handle
@@ -63,7 +77,7 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Main row */} {/* Main row */}
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2"> <div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
{/* Icon */} {/* Icon */}
<div <div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0" className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
@@ -84,24 +98,43 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
> >
{data.label} {data.label}
</div> </div>
{data.ip && ( {data.ip && splitIps(data.ip).map((ip) => (
<div <div
key={ip}
className="font-mono text-[10px] truncate" className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }} style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip} title={ip}
> >
{hideIp ? maskIp(data.ip) : data.ip} {hideIp ? maskIp(ip) : ip}
</div> </div>
)} ))}
</div> </div>
</div> </div>
{/* Hardware section */} {/* Properties section (new system) */}
{showHardware && ( {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">
{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 }}>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div>
)
})}
</div>
</>
)}
{/* Legacy hardware section — fallback for nodes not yet migrated */}
{showLegacyHardware && (
<> <>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} /> <div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5"> <div className="flex flex-col gap-1 px-2.5 py-1.5">
{/* Line 1: CPU */}
{(data.cpu_model || data.cpu_count != null) && ( {(data.cpu_model || data.cpu_count != null) && (
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}> <div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" /> <Cpu size={9} className="shrink-0" />
@@ -113,7 +146,6 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
)} )}
</div> </div>
)} )}
{/* Line 2: RAM + Disk */}
{(data.ram_gb != null || data.disk_gb != null) && ( {(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}> <div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && ( {data.ram_gb != null && (
@@ -141,13 +173,26 @@ export function BaseNode({ data, selected, icon: typeIcon, width, height }: Base
title={data.status} title={data.status}
/> />
<Handle {(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
type="source" const sourceId = BOTTOM_HANDLE_IDS[idx]
position={Position.Bottom} const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
id="bottom" return (
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} <span key={sourceId}>
/> <Handle
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} /> type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
</div> </div>
) )
} }
@@ -0,0 +1,126 @@
import { useState } from 'react'
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
import { Layers, Pencil, Check, X } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { STATUS_COLORS, type NodeData } from '@/types'
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
const showBorder = data.custom_colors?.show_border !== false
const isVisible = showBorder || selected
const [editing, setEditing] = useState(false)
const [labelDraft, setLabelDraft] = useState(data.label)
const children = nodes.filter((n) => n.parentId === id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const unknownCount = children.length - onlineCount - offlineCount
const handleRename = () => {
if (labelDraft.trim()) {
snapshotHistory()
updateNode(id, { label: labelDraft.trim() })
}
setEditing(false)
}
const borderColor = selected ? '#00d4ff' : '#30363d'
const borderStyle = selected ? 'solid' : 'dashed'
return (
<div
style={{
width: '100%',
height: '100%',
position: 'relative',
borderRadius: 8,
border: isVisible ? `2px ${borderStyle} ${borderColor}` : '2px solid transparent',
background: 'transparent',
transition: 'border-color 0.15s, background 0.15s',
boxSizing: 'border-box',
}}
>
<NodeResizer
isVisible={selected}
minWidth={120}
minHeight={80}
lineStyle={{ stroke: '#00d4ff', strokeWidth: 1 }}
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
/>
{/* Header */}
{isVisible && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: '5px 10px',
display: 'flex',
alignItems: 'center',
gap: 6,
background: selected ? 'rgba(0,212,255,0.08)' : 'rgba(22,27,34,0.8)',
borderRadius: '6px 6px 0 0',
borderBottom: isVisible ? `1px solid ${borderColor}40` : 'none',
pointerEvents: 'auto',
}}
>
<Layers size={12} style={{ color: '#00d4ff', flexShrink: 0 }} />
{editing ? (
<input
autoFocus
className="nodrag"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename()
if (e.key === 'Escape') { setLabelDraft(data.label); setEditing(false) }
}}
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
color: '#e6edf3',
fontSize: 11,
fontWeight: 600,
}}
/>
) : (
<span style={{ flex: 1, fontSize: 11, fontWeight: 600, color: '#e6edf3', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{data.label}
</span>
)}
{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
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"
>
<Pencil size={10} />
</button>
)}
{/* Status summary */}
{children.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount}</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount}</span>}
{unknownCount > 0 && <span style={{ color: STATUS_COLORS.unknown }}> {unknownCount}</span>}
</div>
)}
</div>
)}
</div>
)
}
@@ -1,4 +1,5 @@
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { useState } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData, TextPosition } from '@/types' import type { NodeData, TextPosition } from '@/types'
@@ -26,8 +27,16 @@ const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' }, 'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
} }
const HANDLE_SIDES = [
{ id: 'zone-top', position: Position.Top },
{ id: 'zone-right', position: Position.Right },
{ id: 'zone-bottom', position: Position.Bottom },
{ id: 'zone-left', position: Position.Left },
] as const
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) { export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId) const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
const [hovered, setHovered] = useState(false)
const rc = data.custom_colors ?? {} const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff' const borderColor = rc.border ?? '#00d4ff'
@@ -60,6 +69,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
} }
const handleStyle: React.CSSProperties = {
width: 10,
height: 10,
background: borderColor,
border: '2px solid #0d1117',
borderRadius: '50%',
opacity: hovered ? 1 : 0,
transition: 'opacity 0.15s',
}
return ( return (
<> <>
<NodeResizer <NodeResizer
@@ -73,8 +92,16 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
background: '#00d4ff', background: '#00d4ff',
border: '1px solid #0d1117', border: '1px solid #0d1117',
}} }}
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }} lineStyle={{ borderColor: 'transparent' }}
/> />
{HANDLE_SIDES.map(({ id: hid, position }) => (
<span key={hid}>
<Handle type="source" id={hid} position={position} style={handleStyle} />
<Handle type="target" id={`${hid}-t`} position={position} style={{ ...handleStyle, opacity: 0, width: 14, height: 14 }} />
</span>
))}
<div <div
style={{ style={{
position: 'relative', position: 'relative',
@@ -86,11 +113,14 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
justifyContent: posStyle.justifyContent, justifyContent: posStyle.justifyContent,
padding: 12, padding: 12,
background: backgroundColor, background: backgroundColor,
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`, border: `${borderWidth}px ${borderStyle} ${borderColor}`,
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
borderRadius: 10, borderRadius: 10,
boxSizing: 'border-box', boxSizing: 'border-box',
cursor: 'default', cursor: 'default',
}} }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDoubleClick={(e) => { onDoubleClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingGroupRectId(id) setEditingGroupRectId(id)
@@ -1,7 +1,10 @@
import { createElement } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
import { resolvePropertyIcon } from '@/utils/propertyIcons'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
@@ -41,6 +44,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
const glow = colors.border const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
return ( return (
<> <>
@@ -80,7 +84,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
background: theme.colors.nodeIconBackground, background: theme.colors.nodeIconBackground,
}} }}
> >
<Layers size={12} /> {createElement(resolvedIcon, { size: 12 })}
</div> </div>
<div className="flex flex-col min-w-0 flex-1"> <div className="flex flex-col min-w-0 flex-1">
<span <span
@@ -106,6 +110,27 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
/> />
</div> </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-[60px] 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 */} {/* Inner area — React Flow places children here */}
<div className="flex-1 relative" /> <div className="flex-1 relative" />
</div> </div>
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react' import { type NodeProps, type Node } from '@xyflow/react'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
} from 'lucide-react' } from 'lucide-react'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
@@ -22,5 +22,6 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} /> export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} /> export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} /> export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} /> export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} /> export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,6 +1,7 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
export const nodeTypes = { export const nodeTypes = {
isp: IspNode, isp: IspNode,
@@ -17,7 +18,9 @@ export const nodeTypes = {
printer: PrinterNode, printer: PrinterNode,
computer: ComputerNode, computer: ComputerNode,
cpl: CplNode, cpl: CplNode,
docker: DockerNode, docker_host: DockerHostNode,
docker_container: DockerContainerNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode, groupRect: GroupRectNode,
group: GroupNode,
} }
+17 -5
View File
@@ -10,11 +10,12 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][] const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
type AnimMode = 'none' | 'snake' | 'flow' type AnimMode = 'none' | 'basic' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode { function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake' if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow' if (v === 'flow') return 'flow'
if (v === 'basic') return 'basic'
return 'none' return 'none'
} }
@@ -23,11 +24,12 @@ interface EdgeModalProps {
onClose: () => void onClose: () => void
onSubmit: (data: EdgeData) => void onSubmit: (data: EdgeData) => void
onDelete?: () => void onDelete?: () => void
onClearWaypoints?: () => void
initial?: Partial<EdgeData> initial?: Partial<EdgeData>
title?: string title?: string
} }
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) { export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) {
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet') const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
const [label, setLabel] = useState(initial?.label ?? '') const [label, setLabel] = useState(initial?.label ?? '')
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '') const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
@@ -126,7 +128,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label> <Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]"> <div className="flex rounded-md overflow-hidden border border-[#30363d]">
{(['none', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button <button
key={mode} key={mode}
type="button" type="button"
@@ -135,10 +137,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
style={{ style={{
background: animation === mode ? '#00d4ff22' : '#21262d', background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e', color: animation === mode ? '#00d4ff' : '#8b949e',
borderRight: i < 2 ? '1px solid #30363d' : undefined, borderRight: i < 3 ? '1px solid #30363d' : undefined,
}} }}
> >
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'} {mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
</button> </button>
))} ))}
</div> </div>
@@ -175,6 +177,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
</label> </label>
</div> </div>
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
<button
type="button"
onClick={() => { onClearWaypoints(); onClose() }}
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
>
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
</button>
)}
<div className="flex justify-between gap-2 pt-1"> <div className="flex justify-between gap-2 pt-1">
{onDelete ? ( {onDelete ? (
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}> <Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
@@ -0,0 +1,71 @@
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,6 +5,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types' import type { TextPosition } from '@/types'
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
@@ -204,23 +205,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label> <Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => ( {colorFields.map(({ key, label }) => {
<div key={key} className="flex flex-col gap-1 items-center"> const { hex6, alpha } = hexToRgba(form[key])
<label return (
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden" <div key={key} className="flex flex-col gap-1 items-center">
style={{ borderColor: '#30363d' }} <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>
<input <input
type="color" type="range"
value={form[key]} min={0}
onChange={(e) => set(key, e.target.value)} max={100}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0" value={alpha}
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
title={`Opacity: ${alpha}%`}
/> />
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} /> <span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
</label> </div>
<span className="text-[9px] text-muted-foreground/60">{label}</span> )
</div> })}
))}
</div> </div>
</div> </div>
+131 -169
View File
@@ -1,4 +1,4 @@
import { createElement, useState } from 'react' import { Fragment, createElement, useState } from 'react'
import { RotateCcw, ChevronDown } from 'lucide-react' import { RotateCcw, ChevronDown } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -7,16 +7,28 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons' import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] }, { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
] ]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] 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> = { const DEFAULT_DATA: Partial<NodeData> = {
type: 'server', type: 'server',
@@ -26,7 +38,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
status: 'unknown', status: 'unknown',
check_method: 'ping', check_method: 'ping',
services: [], services: [],
container_mode: true, container_mode: false,
custom_colors: undefined, custom_colors: undefined,
custom_icon: undefined, custom_icon: undefined,
} }
@@ -37,20 +49,16 @@ interface NodeModalProps {
onSubmit: (data: Partial<NodeData>) => void onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData> initial?: Partial<NodeData>
title?: string title?: string
proxmoxNodes?: { id: string; label: string }[] parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
} }
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
// NodeModal is always mounted with a key that changes on open/edit, so useState // NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough no need for a reset effect. // initial value is enough - no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) { export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial }) const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('') const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false) const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = useState(false) const [labelError, setLabelError] = useState(false)
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
const set = (key: keyof NodeData, value: unknown) => const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
@@ -62,10 +70,19 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return return
} }
setLabelError(false) setLabelError(false)
onSubmit(form) const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
onSubmit({
...form,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose() onClose()
} }
const filteredParentNodes = form.type === 'docker_container'
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
: parentContainerNodes
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md"> <DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
@@ -75,18 +92,18 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2"> <form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* Type */} {/* Type + Icon on the same row */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Type</Label> <Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}> <Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
<SelectValue /> <SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPE_GROUPS.map((group, i) => ( {NODE_TYPE_GROUPS.map((group, i) => (
<> <Fragment key={group.label}>
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />} {i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectGroup key={group.label}> <SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1"> <SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label} {group.label}
</SelectLabel> </SelectLabel>
@@ -96,14 +113,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
</> </Fragment>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Icon */} {/* Icon */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label> <Label className="text-xs text-muted-foreground">Icon</Label>
{form.custom_icon && ( {form.custom_icon && (
@@ -120,70 +137,72 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<button <button
type="button" type="button"
onClick={() => setIconPickerOpen((o) => !o)} onClick={() => setIconPickerOpen((o) => !o)}
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors" className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2 min-w-0">
{(() => { {(() => {
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon) const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) { if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></> return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
} }
return <span className="text-muted-foreground">Default (from type)</span> const defaultIcon = NODE_TYPE_DEFAULT_ICONS[form.type as NodeType] ?? NODE_TYPE_DEFAULT_ICONS.generic
return <>{createElement(defaultIcon, { size: 13, className: 'text-muted-foreground shrink-0' })}<span className="text-muted-foreground truncate">Default</span></>
})()} })()}
</span> </span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} /> <ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button> </button>
{/* Inline picker panel */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div> </div>
{/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Label */} {/* Label */}
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label> <Label className="text-xs text-muted-foreground">Label *</Label>
@@ -209,11 +228,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* IP */} {/* IP */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address</Label> <Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
<Input <Input
value={form.ip ?? ''} value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)} onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x" placeholder="192.168.1.x, 2001:db8::1"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/> />
</div> </div>
@@ -223,11 +242,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Check Method</Label> <Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}> <Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue /> <SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => ( {CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem> <SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -244,20 +263,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
/> />
</div> </div>
{/* Parent Proxmox (VM / LXC only) */} {/* Parent container */}
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && ( {form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label> <Label className="text-xs text-muted-foreground">Parent Container</Label>
<Select <Select
value={form.parent_id ?? 'none'} value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)} onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
> >
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue placeholder="None (standalone)" /> <SelectValue placeholder="None (standalone)">
{form.parent_id
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
: 'None (standalone)'}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem> <SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{proxmoxNodes.map((n) => ( {filteredParentNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem> <SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -265,24 +288,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div> </div>
)} )}
{/* Container mode (proxmox only) */} {/* Container mode */}
{form.type === 'proxmox' && ( {CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
<div className="flex items-center justify-between col-span-2 py-1"> <div className="flex items-center justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Container Mode</Label> <Label className="text-xs text-muted-foreground">Container Mode</Label>
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span> <span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
</div> </div>
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={!!form.container_mode} aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)} onClick={() => set('container_mode', !form.container_mode)}
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" className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }} style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
> >
<span <span
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform" className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }} style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
/> />
</button> </button>
</div> </div>
@@ -332,85 +355,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)} )}
</div> </div>
{/* Hardware specs (hidden for groupRect) */} {/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && ( {form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-2 col-span-2"> <div className="flex flex-col gap-1.5 col-span-2">
<div className="flex items-center justify-between w-full"> <Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<button <Select
type="button" value={String(form.bottom_handles ?? 1)}
onClick={() => setHardwareOpen((o) => !o)} onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" >
> <SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<span className="font-medium">Hardware</span> <SelectValue />
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} /> </SelectTrigger>
</button> <SelectContent className="bg-[#21262d] border-[#30363d]">
{hardwareOpen && ( <SelectItem value="1" className="text-sm">1 - center</SelectItem>
<div className="flex items-center gap-1.5"> <SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<span className="text-[10px] text-muted-foreground/60">Show on node</span> <SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<button <SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
type="button" </SelectContent>
role="switch" </Select>
aria-checked={!!form.show_hardware}
onClick={() => set('show_hardware', !form.show_hardware)}
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
>
<span
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
</div>
{hardwareOpen && (
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">CPU Model</Label>
<Input
value={form.cpu_model ?? ''}
onChange={(e) => set('cpu_model', e.target.value || undefined)}
placeholder="e.g. Intel Xeon E5-2680"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
<Input
type="number"
min={1}
value={form.cpu_count ?? ''}
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
placeholder="e.g. 8"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
<Input
type="number"
min={0}
step={0.5}
value={form.ram_gb ?? ''}
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 32"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
<Input
type="number"
min={0}
step={1}
value={form.disk_gb ?? ''}
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
placeholder="e.g. 500"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
</div>
)}
</div> </div>
)} )}
@@ -442,4 +404,4 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }
@@ -19,6 +19,7 @@ export interface PendingDevice {
services: Service[] services: Service[]
suggested_type: string | null suggested_type: string | null
status: string status: string
discovery_source: string | null
discovered_at: string discovered_at: string
} }
@@ -77,7 +78,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const handleApprove = () => { onApprove(device); onClose() } const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() } const handleHide = () => { onHide(device); onClose() }
const handleIgnore = () => { onIgnore(device); onClose() } const handleIgnore = () => { onIgnore(device); onClose() }
@@ -101,7 +102,10 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
{device.suggested_type && ( {device.suggested_type && (
<InfoRow label="Type" value={device.suggested_type} /> <InfoRow label="Type" value={device.suggested_type} />
)} )}
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} /> {device.discovery_source && (
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
)}
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
</div> </div>
{/* Services */} {/* Services */}
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2, Settings } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -15,40 +15,29 @@ interface ScanConfigModalProps {
export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) { export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) {
const [ranges, setRanges] = useState<string[]>(['']) const [ranges, setRanges] = useState<string[]>([''])
const [interval, setInterval] = useState(60)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
scanApi.getConfig() scanApi.getConfig()
.then((res) => { .then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']))
setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])
setInterval(res.data.interval_seconds)
})
.catch(() => {/* use defaults */}) .catch(() => {/* use defaults */})
}, [open]) }, [open])
const handleSave = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
setSaving(true)
try {
await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval })
toast.success('Scan config saved')
onClose()
} catch {
toast.error('Failed to save config')
} finally {
setSaving(false)
}
}
const handleScanNow = async () => { const handleScanNow = async () => {
const cleaned = ranges.map((r) => r.trim()).filter(Boolean) const cleaned = ranges.map((r) => r.trim()).filter(Boolean)
if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } if (cleaned.length === 0) { toast.error('Add at least one IP range'); return }
await handleSave() setSaving(true)
onScanNow() try {
onClose() await scanApi.saveConfig({ ranges: cleaned })
await scanApi.trigger()
onScanNow()
onClose()
} catch {
toast.error('Failed to start scan')
} finally {
setSaving(false)
}
} }
return ( return (
@@ -95,23 +84,14 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
</Button> </Button>
</div> </div>
{/* Status check interval */} <p className="text-xs text-muted-foreground flex items-center gap-1.5">
<div className="space-y-1.5"> <Settings size={11} />
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label> Status check interval can be configured in the sidebar Settings.
<Input </p>
type="number"
min={10}
max={3600}
value={interval}
onChange={(e) => setInterval(Number(e.target.value))}
className="font-mono text-sm bg-[#0d1117] border-border w-32"
/>
</div>
</div> </div>
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button> <Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
<Button <Button
onClick={handleScanNow} onClick={handleScanNow}
disabled={saving} disabled={saving}
+61 -14
View File
@@ -1,34 +1,61 @@
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect } from 'react'
import { useReactFlow } from '@xyflow/react' import { useReactFlow } from '@xyflow/react'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
interface SearchModalProps { interface SearchModalProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
onOpenPending: (deviceId: string) => void
} }
export function SearchModal({ open, onClose }: SearchModalProps) { export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
const nodes = useCanvasStore((s) => s.nodes) const nodes = useCanvasStore((s) => s.nodes)
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode) const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const { fitView } = useReactFlow() const { fitView } = useReactFlow()
useEffect(() => {
if (!open) return
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
}, [open])
const searchable = nodes.filter((n) => n.data.type !== 'groupRect') const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
const q = query.toLowerCase() const q = query.toLowerCase()
const results = q.length === 0 ? [] : searchable.filter((n) =>
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
n.data.label?.toLowerCase().includes(q) || n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) || n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q) n.data.hostname?.toLowerCase().includes(q)
).slice(0, 8) ).slice(0, 6)
const handleSelect = useCallback((nodeId: string) => { const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
)
).slice(0, 4)
const totalResults = nodeResults.length + pendingResults.length
const handleSelectNode = useCallback((nodeId: string) => {
setSelectedNode(nodeId) setSelectedNode(nodeId)
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 }) fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
onClose() onClose()
setQuery('') setQuery('')
}, [fitView, setSelectedNode, onClose]) }, [fitView, setSelectedNode, onClose])
const handleSelectPending = useCallback((deviceId: string) => {
onOpenPending(deviceId)
onClose()
setQuery('')
}, [onOpenPending, onClose])
if (!open) return null if (!open) return null
return ( return (
@@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
autoFocus autoFocus
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search nodes by label, IP, hostname…" placeholder="Search nodes, pending devices by IP or service…"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') { onClose(); setQuery('') } if (e.key === 'Escape') { onClose(); setQuery('') }
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id) if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
}} }}
/> />
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd> <kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
</div> </div>
{results.length > 0 && ( {totalResults > 0 && (
<ul className="py-1 max-h-64 overflow-y-auto"> <ul className="py-1 max-h-72 overflow-y-auto">
{results.map((node) => ( {nodeResults.map((node) => (
<li <li
key={node.id} key={node.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer" className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelect(node.id)} 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.data.type}</span>
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span> <span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
)} )}
</li> </li>
))} ))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<li className="px-4 py-1">
<div className="h-px bg-border" />
</li>
)}
{pendingResults.map((device) => {
const serviceName = device.services.find((s) => s.service_name)?.service_name
return (
<li
key={device.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectPending(device.id)}
>
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
</li>
)
})}
</ul> </ul>
)} )}
{q.length > 0 && results.length === 0 && ( {q.length > 0 && totalResults === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p> <p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
)} )}
{q.length === 0 && ( {q.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes</p> <p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices</p>
)} )}
</div> </div>
</div> </div>
@@ -122,6 +122,21 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('flow') expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
}) })
it('selecting Basic sends animated: "basic"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Basic'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('pre-fills animation from initial "basic" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'basic' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
it('selecting None after Snake omits animated from payload', () => { it('selecting None after Snake omits animated from payload', () => {
const onSubmit = vi.fn() const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />) render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -187,4 +202,55 @@ describe('EdgeModal', () => {
expect(onDelete).toHaveBeenCalledOnce() expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
// ── Waypoints / Clear path ────────────────────────────────────────────────
it('does not show Clear path button when onClearWaypoints is not provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
expect(screen.queryByText(/Clear path/)).toBeNull()
})
it('does not show Clear path button when waypoints are empty', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
expect(screen.queryByText(/Clear path/)).toBeNull()
})
it('does not show Clear path button when no initial waypoints', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
expect(screen.queryByText(/Clear path/)).toBeNull()
})
it('shows Clear path button with count when waypoints exist', () => {
render(
<EdgeModal
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
/>,
)
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
})
it('shows singular "point" when only one waypoint', () => {
render(
<EdgeModal
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
/>,
)
expect(screen.getByText('Clear path (1 point)')).toBeDefined()
})
it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
const onClearWaypoints = vi.fn()
const onClose = vi.fn()
render(
<EdgeModal
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
/>,
)
fireEvent.click(screen.getByText('Clear path (1 point)'))
expect(onClearWaypoints).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
}) })
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ExportModal } from '../ExportModal'
const mockExportToPng = vi.fn()
vi.mock('@/utils/export', () => ({
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
EXPORT_QUALITY_OPTIONS: [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
],
}))
const el = document.createElement('div')
const getElement = () => el
const onClose = vi.fn()
describe('ExportModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportToPng.mockResolvedValue(undefined)
})
it('renders all three quality options', () => {
render(<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,4 +251,60 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid') 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()
})
}) })
@@ -1,170 +1,362 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { NodeModal } from '../NodeModal' import { NodeModal } from '../NodeModal'
import type { NodeData } from '@/types'
// ── Mock Shadcn Select with native <select> for testability ───────────────
vi.mock('@/components/ui/select', () => ({
Select: ({ value, onValueChange, children }: {
value?: string; onValueChange?: (v: string) => void; children: React.ReactNode
}) => (
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectGroup: ({ children }: { children: React.ReactNode }) => <>{children}</>,
SelectLabel: () => null,
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<option value={value}>{children}</option>
),
SelectSeparator: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────
function renderModal(props: Partial<Parameters<typeof NodeModal>[0]> = {}) {
const onClose = vi.fn()
const onSubmit = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
return { onClose, onSubmit }
}
/** Get <select> elements in document order: [0]=Type, [1]=CheckMethod, [2]=BottomHandles */
function selects() { return screen.getAllByRole('combobox') as HTMLSelectElement[] }
const BASE: Partial<NodeData> = {
type: 'server', label: 'My Server', hostname: 'server.lan',
ip: '192.168.1.10', check_method: 'ping', services: [],
}
// ── Tests ─────────────────────────────────────────────────────────────────
describe('NodeModal', () => { describe('NodeModal', () => {
// ── Visibility ────────────────────────────────────────────────────────
it('renders nothing when closed', () => { it('renders nothing when closed', () => {
const { container } = render( const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull() expect(container.querySelector('[role="dialog"]')).toBeNull()
}) })
it('renders form fields when open', () => { it('renders form fields when open', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) renderModal()
expect(screen.getByPlaceholderText('My Server')).toBeDefined() expect(screen.getByPlaceholderText('My Server')).toBeDefined()
expect(screen.getByText('Add Node')).toBeDefined() expect(screen.getByText('Add Node')).toBeDefined()
}) })
it('does not call onSubmit when label is empty and shows error', () => { it('shows "Add" button for default title', () => {
const onSubmit = vi.fn() renderModal()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />) expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
fireEvent.click(screen.getByText('Add')) })
it('shows "Save" button when title is Edit Node', () => {
renderModal({ title: 'Edit Node' })
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
it('pre-fills form from initial prop', () => {
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')
})
// ── Cancel ────────────────────────────────────────────────────────────
it('calls onClose when Cancel is clicked', () => {
const { onClose } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
// ── Label validation ──────────────────────────────────────────────────
it('blocks submit and shows error when label is empty', () => {
const { onSubmit } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).not.toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Label is required')).toBeDefined() expect(screen.getByText('Label is required')).toBeDefined()
}) })
it('calls onSubmit with form data when label is filled', () => { it('blocks submit when label is whitespace only', () => {
const onSubmit = vi.fn() const { onSubmit } = renderModal()
const onClose = vi.fn() fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } }) expect(onSubmit).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
expect(onClose).toHaveBeenCalledOnce()
}) })
it('clears label error when user starts typing', () => { it('clears label error when user starts typing', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) renderModal()
fireEvent.click(screen.getByText('Add')) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(screen.getByText('Label is required')).toBeDefined()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } }) fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
expect(screen.queryByText('Label is required')).toBeNull() expect(screen.queryByText('Label is required')).toBeNull()
}) })
it('pre-fills form from initial prop', () => { // ── Form submission ───────────────────────────────────────────────────
render(
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
)
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
expect(input.value).toBe('Pre-filled')
})
it('shows Save button text when title is Edit Node', () => { it('calls onSubmit and onClose with form data on valid submit', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />) const { onSubmit, onClose } = renderModal({ initial: BASE })
expect(screen.getByText('Save')).toBeDefined() fireEvent.click(screen.getByRole('button', { name: 'Add' }))
}) expect(onSubmit).toHaveBeenCalledOnce()
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.label).toBe('My Server')
expect(data.type).toBe('server')
}) })
describe('Hardware section', () => { it('submits updated hostname, IP and notes', () => {
it('renders Hardware toggle button', () => { const { onSubmit } = renderModal({ initial: BASE })
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
expect(screen.getByText('Hardware')).toBeDefined() fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } })
}) fireEvent.change(screen.getByPlaceholderText('Optional notes'), { target: { value: 'rack A' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(data.hostname).toBe('nas.local')
expect(data.ip).toBe('10.0.0.1')
expect(data.notes).toBe('rack A')
})
it('hardware fields are hidden by default', () => { it('resets form values when reopened in Add mode', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) const onClose = vi.fn()
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull() const onSubmit = vi.fn()
})
it('expands hardware fields on toggle click', () => { const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
fireEvent.click(screen.getByText('Hardware')) fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
})
it('submits hardware fields when filled', () => { rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
const onSubmit = vi.fn() rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.cpu_model).toBe('Intel i7-12700K')
expect(submitted.cpu_count).toBe(12)
expect(submitted.ram_gb).toBe(64)
expect(submitted.disk_gb).toBe(2000)
})
it('auto-expands when initial has hardware data', () => { expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
render( expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
<NodeModal })
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
/>
)
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
})
it('hides hardware section for groupRect type', () => { it('submits check_target', () => {
render( const { onSubmit } = renderModal({ initial: BASE })
<NodeModal fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
open fireEvent.click(screen.getByRole('button', { name: 'Add' }))
onClose={vi.fn()} expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
onSubmit={vi.fn()} })
initial={{ type: 'groupRect' }}
/>
)
expect(screen.queryByText('Hardware')).toBeNull()
})
it('show on node toggle is hidden when section is collapsed', () => { // ── Type selector ─────────────────────────────────────────────────────
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByText('Show on node')).toBeNull()
})
it('show on node toggle appears when section is expanded', () => { it('pre-fills type from initial', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />) renderModal({ initial: { ...BASE, type: 'router' } })
fireEvent.click(screen.getByText('Hardware')) expect(selects()[0].value).toBe('router')
expect(screen.getByText('Show on node')).toBeDefined() })
})
it('show_hardware defaults to false', () => { it('changes type and submits it', () => {
const onSubmit = vi.fn() const { onSubmit } = renderModal({ initial: BASE })
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />) fireEvent.change(selects()[0], { target: { value: 'nas' } })
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } }) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.click(screen.getByText('Add')) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy() })
})
it('toggling show on node sets show_hardware to true', () => { // ── Check method ──────────────────────────────────────────────────────
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
fireEvent.click(screen.getByText('Hardware'))
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByText('Add'))
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
})
it('pre-fills show_hardware from initial prop', () => { it('pre-fills check_method from initial', () => {
const onSubmit = vi.fn() renderModal({ initial: { ...BASE, check_method: 'http' } })
render( expect(selects()[1].value).toBe('http')
<NodeModal })
open
onClose={vi.fn()} it('changes check_method and submits it', () => {
onSubmit={onSubmit} const { onSubmit } = renderModal({ initial: BASE })
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }} fireEvent.change(selects()[1], { target: { value: 'ssh' } })
/> fireEvent.click(screen.getByRole('button', { name: 'Add' }))
) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('ssh')
fireEvent.click(screen.getByText('Add')) })
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
// ── Icon picker ───────────────────────────────────────────────────────
it('shows "Default" label when no custom icon', () => {
renderModal({ initial: BASE })
expect(screen.getByText('Default')).toBeDefined()
})
it('opens icon picker on trigger button click', () => {
renderModal({ initial: BASE })
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
fireEvent.click(screen.getByText('Default'))
expect(screen.getByPlaceholderText('Search icons…')).toBeDefined()
})
it('closes picker and shows icon label after selecting an icon', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
expect(screen.queryByPlaceholderText('Search icons…')).toBeNull()
expect(screen.getByText('Database (SQL/NoSQL)')).toBeDefined()
})
it('submits custom_icon key after picking', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.click(screen.getByTitle('Database (SQL/NoSQL)'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).custom_icon).toBe('database')
})
it('shows Reset button when custom_icon is set', () => {
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
expect(screen.getByRole('button', { name: /Reset/i })).toBeDefined()
})
it('hides Reset button when no custom_icon', () => {
renderModal({ initial: BASE })
expect(screen.queryByRole('button', { name: /Reset/i })).toBeNull()
})
it('resets custom_icon and shows Default on Reset click', () => {
renderModal({ initial: { ...BASE, custom_icon: 'database' } })
fireEvent.click(screen.getByRole('button', { name: /Reset/i }))
expect(screen.getByText('Default')).toBeDefined()
})
it('filters icons by search query', () => {
renderModal({ initial: BASE })
fireEvent.click(screen.getByText('Default'))
fireEvent.change(screen.getByPlaceholderText('Search icons…'), { target: { value: 'grafana' } })
expect(screen.getByTitle('Grafana / Kibana')).toBeDefined()
expect(screen.queryByTitle('Router')).toBeNull()
})
// ── 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 } })
expect(screen.getByText('Container Mode')).toBeDefined()
})
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Container Mode')).toBeNull()
})
it('toggles container_mode on click', () => {
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
})
// ── 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) => {
renderModal({
initial: { ...BASE, type },
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' }] })
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' }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => {
renderModal({ initial: BASE })
expect(screen.getByText('border')).toBeDefined()
expect(screen.getByText('background')).toBeDefined()
expect(screen.getByText('icon')).toBeDefined()
})
it('shows default colors hint when no custom_colors', () => {
renderModal({ initial: BASE })
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
it('shows Reset to defaults when custom_colors are set', () => {
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
expect(screen.getByText('Reset to defaults')).toBeDefined()
})
it('resets custom_colors on Reset to defaults click', () => {
renderModal({ initial: { ...BASE, custom_colors: { border: '#ff0000' } } })
fireEvent.click(screen.getByText('Reset to defaults'))
expect(screen.queryByText('Reset to defaults')).toBeNull()
expect(screen.getByText(/Using default colors for/)).toBeDefined()
})
// ── Bottom connection points ───────────────────────────────────────────
it('shows Bottom Connection Points for server type', () => {
renderModal({ initial: BASE })
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
})
it('hides Bottom Connection Points for groupRect', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
})
it('hides Bottom Connection Points for group', () => {
renderModal({ initial: { ...BASE, type: 'group' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
})
it('defaults bottom_handles to 1', () => {
renderModal({ initial: BASE })
expect(selects()[2].value).toBe('1')
})
it('pre-fills bottom_handles from initial', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
expect(selects()[2].value).toBe('3')
})
it('submits updated bottom_handles', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[2], { target: { value: '4' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
}) })
}) })
@@ -126,7 +126,7 @@ describe('PendingDeviceModal', () => {
// ── Actions ─────────────────────────────────────────────────────────────── // ── Actions ───────────────────────────────────────────────────────────────
it('calls onApprove with the device and onClose when Approve is clicked', () => { it('calls onApprove with the device when Approve is clicked (parent controls close on success)', () => {
const device = makeDevice() const device = makeDevice()
const onApprove = vi.fn() const onApprove = vi.fn()
const onClose = vi.fn() const onClose = vi.fn()
@@ -135,7 +135,7 @@ describe('PendingDeviceModal', () => {
) )
fireEvent.click(screen.getByRole('button', { name: 'Approve' })) fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device) expect(onApprove).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce() expect(onClose).not.toHaveBeenCalled()
}) })
it('calls onHide with the device and onClose when Hide is clicked', () => { it('calls onHide with the device and onClose when Hide is clicked', () => {
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
scanApi: { scanApi: {
getConfig: vi.fn(), getConfig: vi.fn(),
saveConfig: vi.fn(), saveConfig: vi.fn(),
trigger: vi.fn(),
}, },
})) }))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
@@ -13,12 +14,15 @@ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.f
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
const defaultConfig = { data: { ranges: ['192.168.1.0/24'], interval_seconds: 60 } } const defaultConfig = { data: { ranges: ['192.168.1.0/24'] } }
describe('ScanConfigModal', () => { describe('ScanConfigModal', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never) vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockReset()
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never) vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
vi.mocked(scanApi.trigger).mockReset()
vi.mocked(scanApi.trigger).mockResolvedValue({} as never)
vi.mocked(toast.success).mockReset() vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset() vi.mocked(toast.error).mockReset()
}) })
@@ -37,13 +41,6 @@ describe('ScanConfigModal', () => {
expect(input).toBeDefined() expect(input).toBeDefined()
}) })
it('loads interval from API on open', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['10.0.0.0/8'], interval_seconds: 120 } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('adds a new empty range on "Add range" click', async () => { it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
@@ -55,62 +52,40 @@ describe('ScanConfigModal', () => {
it('delete button disabled when only one range', async () => { it('delete button disabled when only one range', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
// Only 1 range → delete button disabled
const trashButtons = document.querySelectorAll('button[disabled]') const trashButtons = document.querySelectorAll('button[disabled]')
expect(trashButtons.length).toBeGreaterThan(0) expect(trashButtons.length).toBeGreaterThan(0)
}) })
it('can remove a range when more than one exist', async () => { it('can remove a range when more than one exist', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'], interval_seconds: 60 } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: ['192.168.1.0/24', '10.0.0.0/8'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
// Both trash buttons should be enabled
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg')) const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
expect(trashButtons.length).toBeGreaterThanOrEqual(2) expect(trashButtons.length).toBeGreaterThanOrEqual(2)
}) })
it('shows error toast and does not save when all ranges are empty', async () => { it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''], interval_seconds: 60 } } as never) vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled()) await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => { await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range') expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
}) })
expect(scanApi.saveConfig).not.toHaveBeenCalled() expect(scanApi.saveConfig).not.toHaveBeenCalled()
}) })
it('saves config and closes on Save click', async () => { it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'], interval_seconds: 60 })
expect(toast.success).toHaveBeenCalledWith('Scan config saved')
expect(onClose).toHaveBeenCalledOnce()
})
})
it('shows error toast when save fails', async () => {
vi.mocked(scanApi.saveConfig).mockRejectedValue(new Error('network'))
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save config')
})
})
it('calls onScanNow after saving on "Scan Now" click', async () => {
const onScanNow = vi.fn() const onScanNow = vi.fn()
const onClose = vi.fn() const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />) render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
await screen.findByDisplayValue('192.168.1.0/24') await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' })) fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => { await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalled() expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(scanApi.trigger).toHaveBeenCalledOnce()
expect(onScanNow).toHaveBeenCalledOnce() expect(onScanNow).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
}) })
}) })
@@ -122,12 +97,11 @@ describe('ScanConfigModal', () => {
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
it('strips whitespace from ranges before saving', async () => { it('strips whitespace from ranges before scanning', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />) render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('192.168.1.0/24') const input = await screen.findByDisplayValue('192.168.1.0/24')
// Type a range with surrounding whitespace
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } }) fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => { await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith( expect(scanApi.saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ ranges: ['10.0.0.0/8'] }) expect.objectContaining({ ranges: ['10.0.0.0/8'] })
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ fitView: mockFitView }), useReactFlow: () => ({ fitView: mockFitView }),
})) }))
vi.mock('@/api/client', () => ({
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const mockOnOpenPending = vi.fn()
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> { function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return { return {
id, id,
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
}) })
it('renders nothing when closed', () => { it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} />) render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull() expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
}) })
it('renders search input when open', () => { it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined() expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
}) })
it('shows "Type to search" hint when query is empty', () => { it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByText(/type to search/i)).toBeDefined() expect(screen.getByText(/type to search/i)).toBeDefined()
}) })
it('shows no results message when query has no matches', () => { it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] }) useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined() expect(screen.getByText(/no results match/i)).toBeDefined()
}) })
it('filters nodes by label', () => { it('filters nodes by label', () => {
useCanvasStore.setState({ useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })], nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined() expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull() expect(screen.queryByText('NAS Server')).toBeNull()
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }), makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
], ],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined() expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull() expect(screen.queryByText('Box B')).toBeNull()
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'B', hostname: 'nas.local' }), makeNode('n2', { label: 'B', hostname: 'nas.local' }),
], ],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined() expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull() expect(screen.queryByText('B')).toBeNull()
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
makeNode('g1', { label: 'Zone A', type: 'groupRect' }), makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
], ],
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined() expect(screen.getByText(/no results match/i)).toBeDefined()
}) })
it('limits results to 8 nodes', () => { it('limits node results to 6', () => {
useCanvasStore.setState({ useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })), nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
}) })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/) const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8) expect(items).toHaveLength(6)
}) })
it('selects node and closes on result click', () => { it('selects node and closes on result click', () => {
const onClose = vi.fn() const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] }) useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox')) fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1') expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => { it('selects first result and closes on Enter key', () => {
const onClose = vi.fn() const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] }) useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
const input = screen.getByPlaceholderText(/search nodes/i) const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } }) fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => { it('closes on Escape key', () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' }) fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce()
}) })
it('closes when clicking backdrop', () => { it('closes when clicking backdrop', () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
// The backdrop is the fixed inset div — clicking it fires onClose // The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop) fireEvent.click(backdrop)
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => { it('does not close when clicking inside the search box', () => {
const onClose = vi.fn() const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />) render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i)) fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled() expect(onClose).not.toHaveBeenCalled()
}) })
it('search is case-insensitive', () => { it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] }) useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} />) render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } }) fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined() expect(screen.getByText('My NAS')).toBeDefined()
}) })
+548 -166
View File
@@ -1,33 +1,87 @@
import { useState } from 'react' import { createElement, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' 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'
interface DetailPanelProps { interface DetailPanelProps {
onEdit: (id: string) => void onEdit: (id: string) => void
} }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
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 }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore() const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId)
const [addingForNode, setAddingForNode] = useState<string | null>(null) const [addingForNode, setAddingForNode] = useState<string | null>(null)
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM) const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null) const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM) const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
const [groupName, setGroupName] = useState('')
const [creatingGroup, setCreatingGroup] = useState(false)
// Properties state
const [addingProp, setAddingProp] = useState(false)
const [newProp, setNewProp] = useState<PropForm>(EMPTY_PROP)
const [editingPropIndex, setEditingPropIndex] = useState<number | null>(null)
const [editProp, setEditProp] = useState<PropForm>(EMPTY_PROP)
// Multi-select panel
const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id))
if (multiSelected.length > 1) {
return (
<MultiSelectPanel
nodeIds={multiSelected}
nodes={nodes}
groupName={groupName}
setGroupName={setGroupName}
creatingGroup={creatingGroup}
setCreatingGroup={setCreatingGroup}
onCreateGroup={(name) => { createGroup(multiSelected, name); setGroupName(''); setCreatingGroup(false) }}
onClose={() => setSelectedNode(null)}
/>
)
}
const node = nodes.find((n) => n.id === selectedNodeId)
if (!node || node.data.type === 'groupRect') return null if (!node || node.data.type === 'groupRect') return null
// Group detail panel
if (node.data.type === 'group') {
return (
<GroupDetailPanel
node={node}
nodes={nodes}
onUngroup={() => { ungroup(node.id) }}
onToggleBorder={() => {
snapshotHistory()
updateNode(node.id, {
custom_colors: {
...node.data.custom_colors,
show_border: !(node.data.custom_colors?.show_border !== false),
},
})
}}
onClose={() => setSelectedNode(null)}
onSelectChild={(id) => setSelectedNode(id)}
/>
)
}
// Normal single-node panel
const addingService = addingForNode === node.id const addingService = addingForNode === node.id
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
const { data } = node const { data } = node
const services = data.services ?? [] const services = data.services ?? []
const statusColor = STATUS_COLORS[data.status] const statusColor = STATUS_COLORS[data.status]
@@ -35,17 +89,23 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleDelete = () => { const handleDelete = () => {
if (confirm(`Delete "${data.label}"?`)) { if (confirm(`Delete "${data.label}"?`)) {
snapshotHistory()
deleteNode(node.id) deleteNode(node.id)
} }
} }
const handleAddService = () => { const handleAddService = () => {
const port = parseInt(newSvc.port, 10) const trimmedPort = newSvc.port.trim()
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory()
const path = newSvc.path.trim()
const svc: ServiceInfo = { const svc: ServiceInfo = {
port, ...(port != null ? { port } : {}),
protocol: newSvc.protocol, protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(), service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
} }
updateNode(node.id, { services: [...services, svc] }) updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM) setNewSvc(EMPTY_FORM)
@@ -53,6 +113,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
} }
const handleRemoveService = (index: number) => { const handleRemoveService = (index: number) => {
snapshotHistory()
const updated = services.filter((_, i) => i !== index) const updated = services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated }) updateNode(node.id, { services: updated })
if (editingIndex === index) setEditingFor(null) if (editingIndex === index) setEditingFor(null)
@@ -61,39 +122,89 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleStartEdit = (index: number) => { const handleStartEdit = (index: number) => {
const svc = services[index] const svc = services[index]
if (!svc) return if (!svc) return
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name }) setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditingFor({ nodeId: node.id, index }) setEditingFor({ nodeId: node.id, index })
setAddingForNode(null) setAddingForNode(null)
} }
const handleSaveEdit = () => { const handleSaveEdit = () => {
if (editingIndex === null) return if (editingIndex === null) return
const port = parseInt(editSvc.port, 10) const trimmedPort = editSvc.port.trim()
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) => const updated = services.map((svc, i) =>
i === editingIndex i === editingIndex
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } ? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc : svc
) )
updateNode(node.id, { services: updated }) updateNode(node.id, { services: updated })
setEditingFor(null) setEditingFor(null)
} }
// --- Property handlers ---
const properties: NodeProperty[] = data.properties ?? []
const handleAddProp = () => {
if (!newProp.key.trim() || !newProp.value.trim()) return
snapshotHistory()
const prop: NodeProperty = { key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible }
updateNode(node.id, { properties: [...properties, prop] })
setNewProp(EMPTY_PROP)
setAddingProp(false)
}
const handleRemoveProp = (index: number) => {
snapshotHistory()
updateNode(node.id, { properties: properties.filter((_, i) => i !== index) })
if (editingPropIndex === index) setEditingPropIndex(null)
}
const handleTogglePropVisible = (index: number) => {
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) => i === index ? { ...p, visible: !p.visible } : p),
})
}
const handleStartEditProp = (index: number) => {
const p = properties[index]
if (!p) return
setEditProp({ key: p.key, value: p.value, icon: p.icon, visible: p.visible })
setEditingPropIndex(index)
setAddingProp(false)
}
const handleSaveEditProp = () => {
if (editingPropIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
snapshotHistory()
updateNode(node.id, {
properties: properties.map((p, i) =>
i === editingPropIndex
? { key: editProp.key.trim(), value: editProp.value.trim(), icon: editProp.icon, visible: editProp.visible }
: p
),
})
setEditingPropIndex(null)
}
return ( return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span> <span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button <button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
aria-label="Close panel"
onClick={() => setSelectedNode(null)}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
{/* Status */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border"> <div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} /> <div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} />
<span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span> <span className="text-sm capitalize" style={{ color: statusColor }}>{data.status}</span>
@@ -102,102 +213,101 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} )}
</div> </div>
{/* Details */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm"> <div className="flex flex-col gap-3 px-4 py-3 text-sm">
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} /> <DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
{data.hostname && ( {data.hostname && (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">Hostname</span> <span className="text-muted-foreground text-xs shrink-0">Hostname</span>
<a <a href={`http://${data.hostname}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.hostname}>
href={`http://${data.hostname}`} {data.hostname}<ExternalLink size={10} className="shrink-0" />
target="_blank" </a>
rel="noopener noreferrer" </div>
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" )}
title={data.hostname} {data.ip && (
> <div className="flex justify-between gap-2 items-baseline">
{data.hostname} <span className="text-muted-foreground text-xs shrink-0">IP Address</span>
<ExternalLink size={10} className="shrink-0" /> <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> </a>
</div> </div>
)} )}
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
{data.mac && <DetailRow label="MAC" value={data.mac} mono />} {data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />} {data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />} {data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && ( {data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
)}
</div> </div>
{/* Hardware */} {/* Properties section */}
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
</div>
)}
{/* Services */}
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
Services{services.length > 0 ? ` (${services.length})` : ''}
</span>
<button <button
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors" className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
> >
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
</div> </div>
{addingProp && (
{/* Add service form */} <PropertyForm
{addingService && ( form={newProp}
<ServiceForm onChange={setNewProp}
form={newSvc} onConfirm={handleAddProp}
onChange={setNewSvc} onCancel={() => { setAddingProp(false); setNewProp(EMPTY_PROP) }}
onConfirm={handleAddService}
onCancel={() => setAddingForNode(null)}
confirmLabel="Add" confirmLabel="Add"
autoFocus
/> />
)} )}
{properties.length > 0 && (
{services.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{services.map((svc, i) => {properties.map((prop, i) =>
editingIndex === i ? ( editingPropIndex === i ? (
<ServiceForm <PropertyForm
key={`edit-${i}`} key={`edit-${i}`}
form={editSvc} form={editProp}
onChange={setEditSvc} onChange={setEditProp}
onConfirm={handleSaveEdit} onConfirm={handleSaveEditProp}
onCancel={() => setEditingFor(null)} onCancel={() => setEditingPropIndex(null)}
confirmLabel="Save" confirmLabel="Save"
autoFocus
/> />
) : ( ) : (
<ServiceBadge <PropertyBadge
key={`${svc.port}-${svc.protocol}-${i}`} key={`${prop.key}-${i}`}
svc={svc} prop={prop}
host={host} onToggleVisible={() => handleTogglePropVisible(i)}
onEdit={() => handleStartEdit(i)} onEdit={() => handleStartEditProp(i)}
onRemove={() => handleRemoveService(i)} onRemove={() => handleRemoveProp(i)}
/> />
) )
)} )}
</div> </div>
)} )}
{properties.length === 0 && !addingProp && (
{services.length === 0 && !addingService && ( <p className="text-[10px] text-muted-foreground/50">No properties click Add to define one.</p>
<p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)} )}
</div> </div>
{/* Notes */} <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
<Plus size={10} /> Add
</button>
</div>
{addingService && <ServiceForm form={newSvc} onChange={setNewSvc} onConfirm={handleAddService} onCancel={() => setAddingForNode(null)} confirmLabel="Add" autoFocus />}
{services.length > 0 && (
<div className="flex flex-col gap-1.5">
{services.map((svc, i) =>
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)} />
)
)}
</div>
)}
{services.length === 0 && !addingService && <p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>}
</div>
{data.notes && ( {data.notes && (
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-1">Notes</div> <div className="text-xs text-muted-foreground mb-1">Notes</div>
@@ -205,7 +315,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
</div> </div>
)} )}
{/* Actions */}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border"> <div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}> <Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit <Edit size={14} /> Edit
@@ -218,135 +327,417 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
) )
} }
function formatStorage(gb: number): string { // --- Multi-select panel ---
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB` interface MultiSelectPanelProps {
nodeIds: string[]
nodes: Node<NodeData>[]
groupName: string
setGroupName: (v: string) => void
creatingGroup: boolean
setCreatingGroup: (v: boolean) => void
onCreateGroup: (name: string) => void
onClose: () => void
} }
function MultiSelectPanel({ nodeIds, nodes, groupName, setGroupName, creatingGroup, setCreatingGroup, onCreateGroup, onClose }: MultiSelectPanelProps) {
const selectedNodes = nodeIds.map((id) => nodes.find((n) => n.id === id)).filter(Boolean) as Node<NodeData>[]
const handleCreate = () => {
const name = groupName.trim() || 'Group'
onCreateGroup(name)
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<Layers size={14} className="text-[#00d4ff]" />
<span className="font-semibold text-sm text-foreground">{nodeIds.length} nodes selected</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors">
<X size={16} />
</button>
</div>
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
{selectedNodes.map((n) => (
<div key={n.id} className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs">
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[n.data.status] }} />
<span className="truncate text-foreground font-medium">{n.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}</span>
</div>
))}
</div>
<div className="px-4 py-3 border-t border-border space-y-2">
{creatingGroup ? (
<>
<Input
autoFocus
placeholder="Group name…"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); if (e.key === 'Escape') setCreatingGroup(false) }}
className="bg-[#21262d] border-[#30363d] text-xs h-7"
/>
<div className="flex gap-2">
<Button size="sm" className="flex-1 h-7 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={handleCreate}>
Create Group
</Button>
<Button size="sm" variant="ghost" className="h-7 text-[10px]" onClick={() => setCreatingGroup(false)}>
Cancel
</Button>
</div>
</>
) : (
<Button
size="sm"
className="w-full gap-2 bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20"
variant="ghost"
onClick={() => setCreatingGroup(true)}
>
<Layers size={13} /> Create Group
</Button>
)}
</div>
</aside>
)
}
// --- Group detail panel ---
interface GroupDetailPanelProps {
node: Node<NodeData>
nodes: Node<NodeData>[]
onUngroup: () => void
onToggleBorder: () => void
onClose: () => void
onSelectChild: (id: string) => void
}
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
const children = nodes.filter((n) => n.parentId === node.id)
const onlineCount = children.filter((n) => n.data.status === 'online').length
const offlineCount = children.filter((n) => n.data.status === 'offline').length
const showBorder = node.data.custom_colors?.show_border !== false
const handleUngroup = () => {
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
onUngroup()
}
}
return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<Layers size={14} className="text-[#00d4ff] shrink-0" />
<span className="font-semibold text-sm text-foreground truncate">{node.data.label}</span>
</div>
<button aria-label="Close panel" onClick={onClose} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
<X size={16} />
</button>
</div>
{/* Status summary */}
<div className="flex items-center gap-4 px-4 py-3 border-b border-border text-xs">
<span className="text-muted-foreground">{children.length} node{children.length !== 1 ? 's' : ''}</span>
{onlineCount > 0 && <span style={{ color: STATUS_COLORS.online }}> {onlineCount} online</span>}
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}> {offlineCount} offline</span>}
</div>
{/* Children list */}
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
{children.map((child) => (
<button
key={child.id}
onClick={() => onSelectChild(child.id)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
<span className="truncate text-foreground font-medium">{child.data.label}</span>
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
</button>
))}
</div>
{/* Actions */}
<div className="px-4 py-3 border-t border-border space-y-2">
<button
onClick={onToggleBorder}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-[#21262d] transition-colors"
>
{showBorder ? <Eye size={13} /> : <EyeOff size={13} />}
{showBorder ? 'Hide border & title' : 'Show border & title'}
</button>
<Button
size="sm"
variant="destructive"
className="w-full gap-2"
onClick={handleUngroup}
>
<Ungroup size={13} /> Ungroup
</Button>
</div>
</aside>
)
}
// --- Helpers ---
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex justify-between gap-2 items-baseline"> <div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">{label}</span> <span className="text-muted-foreground text-xs shrink-0">{label}</span>
<span <span className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`} title={value}>
className={`text-xs text-right truncate ${mono ? 'font-mono text-[#00d4ff]' : 'text-foreground'}`}
title={value}
>
{value} {value}
</span> </span>
</div> </div>
) )
} }
function ServiceForm({ function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form, form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange, onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
onConfirm,
onCancel,
confirmLabel,
autoFocus,
}: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel: () => void
confirmLabel: string confirmLabel: string
autoFocus?: boolean 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 ( return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]"> <div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input <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()} />
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"> <div className="flex gap-1.5">
<Input <Input
type="number" type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port} value={form.port}
onChange={(e) => onChange({ ...form, port: e.target.value })} onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port" placeholder="Port"
min={1} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
max={65535}
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()} onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/> />
<select <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">
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="tcp">tcp</option>
<option value="udp">udp</option> <option value="udp">udp</option>
</select> </select>
</div> </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"> <div className="flex gap-1.5">
<Button <Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
size="sm" <Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" </div>
onClick={onConfirm} </div>
)
}
// --- Property components ---
function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel }: {
form: PropForm
onChange: (f: PropForm) => void
onConfirm: () => void
onCancel: () => void
confirmLabel: string
}) {
return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={form.key}
onChange={(e) => onChange({ ...form, key: e.target.value })}
placeholder="Label (e.g. CPU Model)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<Input
value={form.value}
onChange={(e) => onChange({ ...form, value: e.target.value })}
placeholder="Value (e.g. i7-12700K)"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
{/* Icon picker */}
<div className="flex flex-wrap gap-1 pt-0.5">
<button
onClick={() => onChange({ ...form, icon: null })}
title="No icon"
className={`w-6 h-6 rounded flex items-center justify-center text-[10px] border transition-colors ${
form.icon === null ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
> >
</button>
{PROPERTY_ICON_NAMES.map((name) => {
const Icon = PROPERTY_ICONS[name]
const active = form.icon === name
return (
<button
key={name}
onClick={() => onChange({ ...form, icon: name })}
title={name}
className={`w-6 h-6 rounded flex items-center justify-center border transition-colors ${
active ? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]' : 'border-[#30363d] text-muted-foreground hover:border-[#8b949e]'
}`}
>
{createElement(Icon, { size: 11 })}
</button>
)
})}
</div>
{/* Visible toggle */}
<label className="flex items-center gap-2 cursor-pointer pt-0.5">
<input
type="checkbox"
checked={form.visible}
onChange={(e) => onChange({ ...form, visible: e.target.checked })}
className="accent-[#00d4ff] w-3 h-3"
/>
<span className="text-[10px] text-muted-foreground">Show on node</span>
</label>
<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} {confirmLabel}
</Button> </Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}> <Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
Cancel </div>
</Button> </div>
)
}
function PropertyBadge({ prop, onToggleVisible, onEdit, onRemove }: {
prop: NodeProperty
onToggleVisible: () => void
onEdit: () => void
onRemove: () => void
}) {
const Icon = resolvePropertyIcon(prop.icon)
return (
<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' }}>
<div className="flex items-center gap-1.5 min-w-0">
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={onToggleVisible}
title={prop.visible ? 'Hide on node' : 'Show on node'}
className="text-[#8b949e] hover:text-[#00d4ff] transition-colors"
>
{prop.visible ? <Eye size={10} /> : <EyeOff size={10} />}
</button>
<button onClick={onEdit} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff]" title="Edit property">
<Pencil size={10} />
</button>
<button onClick={onRemove} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149]" title="Remove property">
<X size={10} />
</button>
</div> </div>
</div> </div>
) )
} }
const CATEGORY_COLORS: Record<string, string> = { const CATEGORY_COLORS: Record<string, string> = {
web: '#00d4ff', web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
database: '#a855f7',
monitoring: '#39d353',
storage: '#e3b341',
security: '#f85149',
remote: '#8b949e',
} }
function ServiceBadge({ function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
svc,
host,
onEdit,
onRemove,
}: {
svc: ServiceInfo
host?: string
onEdit: () => void
onRemove: () => void
}) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' 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() : ''
const inner = ( return (
<div <div
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
style={{ style={{ background: '#21262d', borderColor: '#30363d' }}
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="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} /> {url ? (
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span> <a
</div> href={url}
<div className="flex items-center gap-1.5 shrink-0"> target="_blank"
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span> rel="noopener noreferrer"
{url && <ExternalLink size={10} className="text-muted-foreground" />} 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 <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
title="Edit service" title="Edit service"
> >
<Pencil size={10} /> <Pencil size={10} />
</button> </button>
<button <button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
title="Remove service" title="Remove service"
> >
<X size={10} /> <X size={10} />
@@ -354,13 +745,4 @@ function ServiceBadge({
</div> </div>
</div> </div>
) )
if (url) {
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">
{inner}
</a>
)
}
return inner
} }
+297 -28
View File
@@ -1,15 +1,17 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client' import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
const ALL_VIEWS = [ const ALL_VIEWS = [
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' }, { id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
@@ -35,26 +37,27 @@ interface SidebarProps {
onScan: () => void onScan: () => void
onSave: () => void onSave: () => void
onNodeApproved: (nodeId: string) => void onNodeApproved: (nodeId: string) => void
forceView?: SidebarView
highlightPendingId?: string
} }
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false) const [_collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>('canvas') 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
const activeView = forceView ?? _activeView
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore() const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect') const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
const handleScan = useCallback(async () => { const handleScan = useCallback(() => {
try { onScan()
await scanApi.trigger()
toast.success('Network scan started — check Scan History for results')
setActiveView('history')
onScan()
} catch {
toast.error('Failed to trigger scan')
}
}, [onScan]) }, [onScan])
return ( return (
@@ -92,9 +95,10 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
{/* View content (only when expanded) */} {/* View content (only when expanded) */}
{!collapsed && activeView !== 'canvas' && ( {!collapsed && activeView !== 'canvas' && (
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border"> <div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} />} {activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
{activeView === 'hidden' && <HiddenDevicesPanel />} {activeView === 'hidden' && <HiddenDevicesPanel />}
{activeView === 'history' && <ScanHistoryPanel />} {activeView === 'history' && <ScanHistoryPanel />}
{activeView === 'settings' && <SettingsPanel />}
</div> </div>
)} )}
@@ -141,16 +145,55 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
badge={hasUnsavedChanges} badge={hasUnsavedChanges}
accent accent
/> />
{!STANDALONE && (
<SidebarItem
icon={Settings}
label="Settings"
collapsed={collapsed}
active={activeView === 'settings'}
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/>
)}
{!STANDALONE && (
<SidebarItem
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
/>
)}
</div> </div>
{!collapsed && <VersionBadge />}
</aside> </aside>
) )
} }
function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) { const COMMON_PORTS = new Set([22, 80, 443])
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
const [devices, setDevices] = useState<PendingDevice[]>([]) const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<PendingDevice | null>(null) const [selected, setSelected] = useState<PendingDevice | null>(null)
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
const { addNode, scanEventTs } = useCanvasStore() const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null)
const allChecked = devices.length > 0 && checkedIds.size === devices.length
const someChecked = checkedIds.size > 0
const toggleCheck = (id: string, e: React.MouseEvent) => {
e.stopPropagation()
setCheckedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
const toggleAll = () => {
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
}
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@@ -164,12 +207,73 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
} }
}, []) }, [])
const handleClearAll = async () => {
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(() => { load() }, [load])
useEffect(() => { useEffect(() => {
if (scanEventTs > 0) load() if (scanEventTs > 0) load()
}, [scanEventTs, load]) }, [scanEventTs, load])
useEffect(() => {
if (!highlightId || loading) return
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, [highlightId, loading])
const handleApprove = async (device: PendingDevice) => { const handleApprove = async (device: PendingDevice) => {
try { try {
const nodeData = { const nodeData = {
@@ -190,6 +294,7 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
}) })
toast.success(`Approved ${nodeData.label}`) toast.success(`Approved ${nodeData.label}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id)) setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId) onNodeApproved(nodeId)
} catch { } catch {
toast.error('Failed to approve device') toast.error('Failed to approve device')
@@ -219,18 +324,52 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
<> <>
<div className="p-2"> <div className="p-2">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span> <div className="flex items-center gap-1.5">
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5"> {devices.length > 0 && (
<RefreshCw size={12} /> <input
</button> 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>
<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} />
</button>
{devices.length > 0 && (
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
<X size={12} />
</button>
)}
</div>
</div> </div>
{someChecked && (
<div className="flex items-center gap-1 mb-2">
<button
onClick={handleBulkApprove}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
>
Approve ({checkedIds.size})
</button>
<button
onClick={handleBulkHide}
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
>
Hide ({checkedIds.size})
</button>
</div>
)}
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />} {loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
{!loading && devices.length === 0 && ( {!loading && devices.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p> <p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)} )}
{devices.map((d) => { {devices.map((d) => {
const COMMON_PORTS = new Set([22, 80, 443]) 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 const titleService = namedService
?? d.services.find((s) => s.port === 80) ?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443) ?? d.services.find((s) => s.port === 443)
@@ -242,21 +381,32 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
const hasHttps = d.services.some((s) => s.port === 443) const hasHttps = d.services.some((s) => s.port === 443)
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
const virtualBadge = detectVirtualBadge(d.mac) const virtualBadge = detectVirtualBadge(d.mac)
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
const isHighlighted = d.id === highlightId
return ( return (
<button <button
key={d.id} key={d.id}
ref={isHighlighted ? highlightRef : null}
onClick={() => setSelected(d)} onClick={() => setSelected(d)}
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent hover:border-[#30363d]" className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" /> <input
type="checkbox"
checked={checkedIds.has(d.id)}
onClick={(e) => 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="text-foreground truncate font-medium">{title}</span> <span className="text-foreground truncate font-medium">{title}</span>
</div> </div>
{showIpBelow && ( {showIpBelow && (
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div> <div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
)} )}
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge) && ( {(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap"> <div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
{virtualBadge && ( {virtualBadge && (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
@@ -377,8 +527,26 @@ function ScanHistoryPanel() {
return () => clearInterval(id) return () => clearInterval(id)
}, [runs, load]) }, [runs, load])
const [stopping, setStopping] = useState<string | null>(null)
const handleStop = async (runId: string) => {
setStopping(runId)
try {
await scanApi.stop(runId)
toast.success('Scan stop requested')
} catch {
toast.error('Failed to stop scan')
} finally {
setStopping(null)
}
}
const statusColor = (s: string) => const statusColor = (s: string) =>
s === 'done' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'error' ? '#f85149' : '#8b949e' s === 'done' ? '#39d353'
: s === 'running' ? '#e3b341'
: s === 'error' ? '#f85149'
: s === 'cancelled' ? '#8b949e'
: '#8b949e'
return ( return (
<div className="p-2"> <div className="p-2">
@@ -399,9 +567,27 @@ function ScanHistoryPanel() {
<span className="font-mono text-foreground capitalize">{r.status}</span> <span className="font-mono text-foreground capitalize">{r.status}</span>
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />} {r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span> <span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
{r.status === 'running' && (
<Tooltip>
<TooltipTrigger>
<button
aria-label="Stop scan"
onClick={() => handleStop(r.id)}
disabled={stopping === r.id}
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
>
{stopping === r.id
? <Loader2 size={11} className="animate-spin" />
: <StopCircle size={11} />
}
</button>
</TooltipTrigger>
<TooltipContent side="left">Stop scan</TooltipContent>
</Tooltip>
)}
</div> </div>
<div className="text-muted-foreground text-[10px] mt-0.5"> <div className="text-muted-foreground text-[10px] mt-0.5">
{new Date(r.started_at).toLocaleString()} {new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
</div> </div>
{r.ranges.length > 0 && ( {r.ranges.length > 0 && (
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div> <div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
@@ -417,6 +603,89 @@ function ScanHistoryPanel() {
) )
} }
function SettingsPanel() {
const [interval, setIntervalValue] = useState(60)
const [saving, setSaving] = useState(false)
useEffect(() => {
settingsApi.get()
.then((res) => setIntervalValue(res.data.interval_seconds))
.catch(() => {/* use default */})
}, [])
const handleSave = async () => {
setSaving(true)
try {
await settingsApi.save({ interval_seconds: interval })
toast.success('Settings saved')
} catch {
toast.error('Failed to save settings')
} finally {
setSaving(false)
}
}
return (
<div className="p-3 space-y-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={10}
max={3600}
value={interval}
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
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>
</div>
<p className="text-[10px] text-muted-foreground leading-tight">
How often node health is polled (ping, HTTP, SSH)
</p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
)
}
function VersionBadge() {
const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current)
return (
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
<a
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
v{current}
</a>
{hasUpdate && latest && (
<a
href={latest.url.startsWith('https://') ? 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"
>
v{latest.version} available
</a>
)}
</div>
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = { const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' }, '52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' }, 'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import type { ServiceInfo } from '@/types' import type { ServiceInfo } from '@/types'
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({ const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
port, ...(port != null ? { port } : {}),
protocol, protocol,
service_name, service_name,
...(path ? { path } : {}),
}) })
describe('getServiceUrl', () => { describe('getServiceUrl', () => {
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
it('uses host string directly (works with both IP and hostname)', () => { it('uses host string directly (works with both IP and hostname)', () => {
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80') 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')
})
}) })
@@ -26,9 +26,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode(nodeData)], nodes: [makeNode(nodeData)],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
} }
@@ -37,9 +41,13 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [], nodes: [],
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}) })
@@ -61,58 +69,137 @@ describe('DetailPanel', () => {
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
}) })
describe('Hardware section', () => { describe('Properties section', () => {
it('does not render hardware section when no hardware data', () => { it('renders empty state when no properties', () => {
setupStore({ label: 'Server' }) setupStore({ properties: [] })
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('Hardware')).toBeNull() expect(screen.getByText(/No properties/)).toBeDefined()
}) })
it('renders hardware section when cpu_count is set', () => { it('renders properties with key and value', () => {
setupStore({ cpu_count: 8 }) setupStore({
properties: [
{ key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true },
{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false },
],
})
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined() expect(screen.getByText('CPU Model')).toBeDefined()
expect(screen.getByText('8')).toBeDefined() // Value is rendered with a middle-dot prefix: "· 32 GB"
expect(screen.getByText(/32 GB/)).toBeDefined()
}) })
it('renders cpu_model', () => { it('shows Properties count when properties exist', () => {
setupStore({ cpu_model: 'Intel Xeon E5-2680' }) setupStore({
properties: [
{ key: 'CPU Model', value: 'i7', icon: null, visible: true },
{ key: 'RAM', value: '16 GB', icon: null, visible: true },
],
})
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined() expect(screen.getByText('Properties (2)')).toBeDefined()
}) })
it('formats ram_gb in GB', () => { it('shows add form when Add is clicked', () => {
setupStore({ ram_gb: 32 }) setupStore({ properties: [] })
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('32 GB')).toBeDefined() // There are multiple "Add" buttons (services + properties) — find the one after "Properties"
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[0]) // first Add = properties (rendered above services)
expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined()
}) })
it('formats ram_gb >= 1024 as TB', () => { it('calls updateNode with new property on Add confirm', () => {
setupStore({ ram_gb: 2048 }) const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [] })],
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()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('2 TB')).toBeDefined() const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[0]) // first Add = properties
// Form is now open — fill key and value
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
}) })
it('formats disk_gb in GB', () => { it('calls updateNode with toggled visibility when eye button is clicked', () => {
setupStore({ disk_gb: 500 }) const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })],
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()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('500 GB')).toBeDefined() fireEvent.click(screen.getByTitle('Hide on node'))
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties[0].visible).toBe(false)
}) })
it('formats disk_gb >= 1024 as TB', () => { it('calls updateNode without the property when remove button is clicked', () => {
setupStore({ disk_gb: 1536 }) const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })],
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()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('1.5 TB')).toBeDefined() fireEvent.click(screen.getByTitle('Remove property'))
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties).toHaveLength(0)
}) })
it('renders all hardware fields together', () => { it('does not submit add form when key is empty', () => {
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 }) const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ properties: [] })],
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()} />) render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined() const addButtons = screen.getAllByText('Add')
expect(screen.getByText('AMD EPYC')).toBeDefined() fireEvent.click(addButtons[0])
expect(screen.getByText('16')).toBeDefined() // Only fill value, leave key empty
expect(screen.getByText('128 GB')).toBeDefined() fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
expect(screen.getByText('4 TB')).toBeDefined() const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(confirmButtons[confirmButtons.length - 1])
expect(updateNode).not.toHaveBeenCalled()
}) })
}) })
@@ -125,6 +212,7 @@ describe('DetailPanel', () => {
setSelectedNode, setSelectedNode,
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Close panel')) fireEvent.click(screen.getByLabelText('Close panel'))
@@ -139,33 +227,39 @@ describe('DetailPanel', () => {
expect(onEdit).toHaveBeenCalledWith('n1') expect(onEdit).toHaveBeenCalledWith('n1')
}) })
it('calls deleteNode when delete confirmed', () => { it('calls snapshotHistory then deleteNode when delete confirmed', () => {
const deleteNode = vi.fn() const deleteNode = vi.fn()
const snapshotHistory = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ label: 'My Server' })], nodes: [makeNode({ label: 'My Server' })],
selectedNodeId: 'n1', selectedNodeId: 'n1',
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode, deleteNode,
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
vi.spyOn(window, 'confirm').mockReturnValue(true) vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Delete node')) fireEvent.click(screen.getByLabelText('Delete node'))
expect(snapshotHistory).toHaveBeenCalledOnce()
expect(deleteNode).toHaveBeenCalledWith('n1') expect(deleteNode).toHaveBeenCalledWith('n1')
}) })
it('does not call deleteNode when delete is cancelled', () => { it('does not call deleteNode or snapshotHistory when delete is cancelled', () => {
const deleteNode = vi.fn() const deleteNode = vi.fn()
const snapshotHistory = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})], nodes: [makeNode({})],
selectedNodeId: 'n1', selectedNodeId: 'n1',
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode, deleteNode,
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
vi.spyOn(window, 'confirm').mockReturnValue(false) vi.spyOn(window, 'confirm').mockReturnValue(false)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByLabelText('Delete node')) fireEvent.click(screen.getByLabelText('Delete node'))
expect(snapshotHistory).not.toHaveBeenCalled()
expect(deleteNode).not.toHaveBeenCalled() expect(deleteNode).not.toHaveBeenCalled()
}) })
}) })
@@ -174,7 +268,9 @@ describe('DetailPanel', () => {
it('shows add form when Add is clicked', () => { it('shows add form when Add is clicked', () => {
setupStore({}) setupStore({})
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add')) // Two "Add" buttons: first = properties, second = services
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[addButtons.length - 1])
expect(screen.getByPlaceholderText('Service name')).toBeDefined() expect(screen.getByPlaceholderText('Service name')).toBeDefined()
}) })
@@ -183,19 +279,49 @@ describe('DetailPanel', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({})], nodes: [makeNode({})],
selectedNodeId: 'n1', selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode, updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByText('Add')) // Two "Add" header buttons: first = properties, second = services
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } }) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
const addButtons = screen.getAllByRole('button', { name: 'Add' }) fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
fireEvent.click(addButtons[addButtons.length - 1])
expect(updateNode).toHaveBeenCalledOnce() expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' }) expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
it('allows adding a service without a port', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<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()
}) })
it('calls updateNode without the removed service when X is clicked', () => { it('calls updateNode without the removed service when X is clicked', () => {
@@ -207,6 +333,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode, updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
fireEvent.click(screen.getByTitle('Remove service')) fireEvent.click(screen.getByTitle('Remove service'))
@@ -221,6 +348,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode: vi.fn(), updateNode: vi.fn(),
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow() expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
}) })
@@ -230,7 +358,7 @@ describe('DetailPanel', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => { it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [svc] }) setupStore({ services: [{ ...svc, path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only) // Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service') const editBtn = screen.getByTitle('Edit service')
@@ -239,6 +367,8 @@ describe('DetailPanel', () => {
expect(nameInput.value).toBe('nginx') expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80') 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', () => { it('calls updateNode with updated values on Save', () => {
@@ -249,6 +379,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode, updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
@@ -256,11 +387,13 @@ describe('DetailPanel', () => {
const nameInput = screen.getByPlaceholderText('Service name') const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } }) fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce() expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache') 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].port).toBe(80)
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
}) })
it('cancels edit without updating', () => { it('cancels edit without updating', () => {
@@ -271,6 +404,7 @@ describe('DetailPanel', () => {
setSelectedNode: vi.fn(), setSelectedNode: vi.fn(),
deleteNode: vi.fn(), deleteNode: vi.fn(),
updateNode, updateNode,
snapshotHistory: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>) } as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
@@ -281,4 +415,76 @@ describe('DetailPanel', () => {
expect(screen.getByText('nginx')).toBeDefined() 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')
})
})
}) })
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('@/utils/serviceUrl', () => ({ getServiceUrl: () => null }))
function makeNode(id: string, overrides = {}) {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'online', services: [] },
...overrides,
}
}
function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) {
return {
id,
type: 'group',
position: { x: 76, y: 52 },
data: {
label,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: showBorder },
},
}
}
const mockStore = {
nodes: [],
selectedNodeId: null,
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
}
function setupStore(overrides = {}) {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
...mockStore,
...overrides,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
}
function renderPanel() {
return render(
<TooltipProvider>
<DetailPanel onEdit={vi.fn()} />
</TooltipProvider>,
)
}
describe('MultiSelectPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders multi-select panel when 2+ nodes selected', () => {
const n1 = makeNode('n1', { data: { label: 'Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'Switch', type: 'switch', status: 'offline', services: [] } })
setupStore({
nodes: [n1, n2],
selectedNodeId: null,
selectedNodeIds: ['n1', 'n2'],
})
renderPanel()
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
it('lists selected node labels in multi-select panel', () => {
const n1 = makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const n2 = makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows Create Group button', () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
expect(screen.getByRole('button', { name: /create group/i })).toBeDefined()
})
it('shows name input when Create Group is clicked', async () => {
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'] })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await waitFor(() => {
expect(screen.getByPlaceholderText(/group name/i)).toBeDefined()
})
})
it('calls createGroup with selected ids and entered name', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
const input = await screen.findByPlaceholderText(/group name/i)
fireEvent.change(input, { target: { value: 'DMZ' } })
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'DMZ')
})
it('uses default name "Group" when input is empty', async () => {
const createGroup = vi.fn()
const n1 = makeNode('n1')
const n2 = makeNode('n2')
setupStore({ nodes: [n1, n2], selectedNodeId: null, selectedNodeIds: ['n1', 'n2'], createGroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /create group/i }))
await screen.findByPlaceholderText(/group name/i)
fireEvent.click(screen.getByRole('button', { name: /^create group$/i }))
expect(createGroup).toHaveBeenCalledWith(['n1', 'n2'], 'Group')
})
it('includes groupRect (zone) nodes in multi-select count', () => {
const n1 = makeNode('n1')
const gr = makeNode('gr1', { data: { label: 'Zone', type: 'groupRect', status: 'unknown', services: [] } })
setupStore({ nodes: [n1, gr], selectedNodeId: null, selectedNodeIds: ['n1', 'gr1'] })
renderPanel()
// groupRect included → 2 nodes selected → multi-select panel shown
expect(screen.getByText('2 nodes selected')).toBeDefined()
})
})
describe('GroupDetailPanel', () => {
beforeEach(() => vi.clearAllMocks())
it('renders group name and members heading', () => {
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Group')).toBeDefined()
expect(screen.getByText('Members')).toBeDefined()
})
it('lists children with their labels', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'My Router', type: 'router', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'My NAS', type: 'nas', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.getByText('My NAS')).toBeDefined()
})
it('shows online/offline count in status summary', () => {
const group = makeGroupNode()
const c1 = makeNode('c1', { parentId: 'g1', data: { label: 'A', type: 'server', status: 'online', services: [] } })
const c2 = makeNode('c2', { parentId: 'g1', data: { label: 'B', type: 'server', status: 'offline', services: [] } })
setupStore({ nodes: [group, c1, c2], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/1 online/)).toBeDefined()
expect(screen.getByText(/1 offline/)).toBeDefined()
})
it('shows Ungroup button', () => {
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByRole('button', { name: /ungroup/i })).toBeDefined()
})
it('calls ungroup after confirm', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).toHaveBeenCalledWith('g1')
})
it('does not call ungroup when confirm is cancelled', () => {
const ungroup = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(false)
const group = makeGroupNode()
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], ungroup })
renderPanel()
fireEvent.click(screen.getByRole('button', { name: /ungroup/i }))
expect(ungroup).not.toHaveBeenCalled()
})
it('shows "Hide border & title" when show_border is true', () => {
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/hide border/i)).toBeDefined()
})
it('shows "Show border & title" when show_border is false', () => {
const group = makeGroupNode('g1', 'G', false)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
renderPanel()
expect(screen.getByText(/show border/i)).toBeDefined()
})
it('calls updateNode to toggle show_border off', () => {
const updateNode = vi.fn()
const group = makeGroupNode('g1', 'G', true)
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
renderPanel()
fireEvent.click(screen.getByText(/hide border/i))
expect(updateNode).toHaveBeenCalledWith('g1', expect.objectContaining({
custom_colors: expect.objectContaining({ show_border: false }),
}))
})
it('calls setSelectedNode when a child node is clicked', () => {
const setSelectedNode = vi.fn()
const group = makeGroupNode()
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Child Node Alpha', type: 'server', status: 'online', services: [] } })
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], setSelectedNode })
renderPanel()
fireEvent.click(screen.getByText('Child Node Alpha'))
expect(setSelectedNode).toHaveBeenCalledWith('c1')
})
})
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn(),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: { get: vi.fn(), save: vi.fn() },
}))
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const RUNNING_RUN = {
id: 'run-1',
status: 'running',
ranges: ['192.168.1.0/24'],
devices_found: 2,
started_at: new Date().toISOString(),
finished_at: null,
error: null,
}
const DONE_RUN = {
id: 'run-2',
status: 'done',
ranges: ['192.168.1.0/24'],
devices_found: 3,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
const CANCELLED_RUN = {
id: 'run-3',
status: 'cancelled',
ranges: ['192.168.1.0/24'],
devices_found: 1,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
async function openHistory() {
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
// Wait for runs to load
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
}
describe('ScanHistoryPanel — stop scan', () => {
beforeEach(() => {
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(scanApi.stop).mockReset()
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
})
it('shows stop button only for running scans', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
renderSidebar()
await openHistory()
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
// Exactly one stop button rendered (for the running scan only)
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
expect(stopButtons).toHaveLength(1)
})
it('calls scanApi.stop with the correct run ID on click', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
})
})
it('shows success toast when stop succeeds', async () => {
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
})
})
it('shows error toast when stop fails', async () => {
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
renderSidebar()
await openHistory()
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
fireEvent.click(stopBtn)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
})
})
it('renders cancelled status without stop button or spinner', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
renderSidebar()
await openHistory()
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
// No stop button
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
})
})
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import * as canvasStore from '@/stores/canvasStore'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('@/stores/canvasStore')
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn(),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: {
get: vi.fn(),
save: vi.fn(),
},
}))
import { settingsApi } from '@/api/client'
import { toast } from 'sonner'
function renderSidebar() {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
<TooltipProvider>
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>
</TooltipProvider>
)
}
describe('SettingsPanel', () => {
beforeEach(() => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
it('opens when Settings item is clicked', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await waitFor(() => {
expect(settingsApi.get).toHaveBeenCalledOnce()
})
expect(screen.getByText('Status check interval (s)')).toBeDefined()
})
it('displays interval loaded from API', async () => {
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('120')
expect(input).toBeDefined()
})
it('saves interval via settingsApi on Save click', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const input = await screen.findByDisplayValue('60')
fireEvent.change(input, { target: { value: '180' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
expect(toast.success).toHaveBeenCalledWith('Settings saved')
})
})
it('shows error toast when save fails', async () => {
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
})
})
it('closes panel when Settings is clicked again', async () => {
renderSidebar()
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
await screen.findByText('Status check interval (s)')
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).toBeNull()
})
})
@@ -0,0 +1,391 @@
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: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
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 } }),
save: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/ui/Logo', () => ({
Logo: ({ showText }: { showText: boolean }) => (
<div data-testid="logo" data-show-text={showText} />
),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({
PendingDeviceModal: () => null,
}))
// ── Helpers ───────────────────────────────────────────────────────────────────
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
id,
type,
position: { x: 0, y: 0 },
data: { label: id, type, status, services: [] },
})
const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: mockToggleHideIp,
addNode: vi.fn(),
scanEventTs: 0,
...overrides,
} 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(),
onScan: vi.fn(),
onSave: vi.fn(),
onNodeApproved: vi.fn(),
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('Sidebar', () => {
beforeEach(() => {
mockStore()
mockAuth()
vi.clearAllMocks()
})
// ── Render ─────────────────────────────────────────────────────────────────
it('renders logo and nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByTestId('logo')).toBeInTheDocument()
expect(screen.getByText('Add Node')).toBeInTheDocument()
expect(screen.getByText('Save Canvas')).toBeInTheDocument()
expect(screen.getByText('Scan Network')).toBeInTheDocument()
})
it('shows all view nav items', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Canvas')).toBeInTheDocument()
expect(screen.getByText('Pending Devices')).toBeInTheDocument()
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
expect(screen.getByText('Scan History')).toBeInTheDocument()
})
// ── Stats ──────────────────────────────────────────────────────────────────
it('displays total / online / offline counts from store', () => {
mockStore({
nodes: [
makeNode('n1', 'online'),
makeNode('n2', 'online'),
makeNode('n3', 'offline'),
makeNode('n4', 'unknown'),
],
})
render(<Sidebar {...defaultProps} />)
// Total (excludes groupRect)
expect(screen.getByText('4')).toBeInTheDocument()
// Online
expect(screen.getByText('2')).toBeInTheDocument()
// Offline
expect(screen.getByText('1')).toBeInTheDocument()
})
it('excludes groupRect nodes from stats', () => {
mockStore({
nodes: [
makeNode('n1', 'unknown'), // 1 real node, not online/offline
makeNode('zone', 'unknown', 'groupRect'),
],
})
render(<Sidebar {...defaultProps} />)
// Total row shows 1 (groupRect excluded), online/offline both 0
const totalRow = screen.getByText('Total').closest('div')!
expect(totalRow).toHaveTextContent('1')
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
})
// ── Collapse ───────────────────────────────────────────────────────────────
it('collapses sidebar on toggle button click', () => {
render(<Sidebar {...defaultProps} />)
const aside = screen.getByRole('complementary')
expect(aside).toHaveStyle({ width: '220px' })
const toggle = aside.querySelector('button')!
fireEvent.click(toggle)
expect(aside).toHaveStyle({ width: '48px' })
})
it('hides label text when collapsed', () => {
render(<Sidebar {...defaultProps} />)
const aside = screen.getByRole('complementary')
const toggle = aside.querySelector('button')!
fireEvent.click(toggle)
expect(screen.queryByText('Add Node')).not.toBeInTheDocument()
})
it('hides stats footer when collapsed', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Total')).toBeInTheDocument()
const toggle = screen.getByRole('complementary').querySelector('button')!
fireEvent.click(toggle)
expect(screen.queryByText('Total')).not.toBeInTheDocument()
})
it('shows logo with showText=false when collapsed', () => {
render(<Sidebar {...defaultProps} />)
const logo = screen.getByTestId('logo')
expect(logo).toHaveAttribute('data-show-text', 'true')
const toggle = screen.getByRole('complementary').querySelector('button')!
fireEvent.click(toggle)
expect(logo).toHaveAttribute('data-show-text', 'false')
})
// ── Action callbacks ───────────────────────────────────────────────────────
it('calls onAddNode when Add Node is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Node'))
expect(defaultProps.onAddNode).toHaveBeenCalledOnce()
})
it('calls onAddGroupRect when Add Zone is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Add Zone'))
expect(defaultProps.onAddGroupRect).toHaveBeenCalledOnce()
})
it('calls onSave when Save Canvas is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Save Canvas'))
expect(defaultProps.onSave).toHaveBeenCalledOnce()
})
it('calls toggleHideIp when Hide IPs is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hide IPs'))
expect(mockToggleHideIp).toHaveBeenCalledOnce()
})
it('shows Show IPs label when hideIp is true', () => {
mockStore({ hideIp: true })
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Show IPs')).toBeInTheDocument()
})
// ── Unsaved changes badge ──────────────────────────────────────────────────
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
mockStore({ hasUnsavedChanges: true })
render(<Sidebar {...defaultProps} />)
// The badge is a span sibling of the Save Canvas button icon
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).toBeInTheDocument()
})
it('does not show unsaved badge when no changes', () => {
mockStore({ hasUnsavedChanges: false })
render(<Sidebar {...defaultProps} />)
const saveBtn = screen.getByText('Save Canvas').closest('button')!
const badge = saveBtn.querySelector('span.rounded-full')
expect(badge).not.toBeInTheDocument()
})
// ── Scan action ────────────────────────────────────────────────────────────
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan Network'))
expect(defaultProps.onScan).toHaveBeenCalledOnce()
})
// ── Navigation ─────────────────────────────────────────────────────────────
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Pending Devices'))
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
})
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Hidden Devices'))
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
})
it('shows History panel when Scan History nav item is clicked', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Scan History'))
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
})
it('toggles Settings panel on Settings click', async () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Settings'))
await waitFor(() =>
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
)
// Click the nav button again to close (use role to avoid matching the panel heading)
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())
})
})
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
// ── Mocks ─────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore')
vi.mock('@/api/client', () => ({
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
pending: vi.fn().mockResolvedValue({ data: [] }),
hidden: vi.fn().mockResolvedValue({ data: [] }),
runs: vi.fn().mockResolvedValue({ data: [] }),
stop: vi.fn().mockResolvedValue({}),
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
},
settingsApi: {
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
save: vi.fn().mockResolvedValue({}),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: () => null,
}))
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
vi.mock('@/hooks/useLatestRelease', () => ({
useLatestRelease: vi.fn(),
}))
import { useLatestRelease } from '@/hooks/useLatestRelease'
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderSidebar() {
vi.mocked(useCanvasStore).mockReturnValue({
nodes: [],
hasUnsavedChanges: false,
hideIp: false,
toggleHideIp: vi.fn(),
addNode: vi.fn(),
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
return render(
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>,
)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('VersionBadge', () => {
beforeEach(() => {
vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false })
})
it('displays the current app version', () => {
renderSidebar()
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
})
it('links current version to its GitHub release page', () => {
renderSidebar()
const link = screen.getByText(`v${__APP_VERSION__}`).closest('a')
expect(link).toHaveAttribute(
'href',
`https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`,
)
expect(link).toHaveAttribute('target', '_blank')
})
it('does not show update badge when on latest version', () => {
renderSidebar()
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
})
it('shows update badge when a newer version is available', async () => {
vi.mocked(useLatestRelease).mockReturnValue({
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
hasUpdate: true,
})
renderSidebar()
await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument())
})
it('update badge links to the latest release URL', async () => {
vi.mocked(useLatestRelease).mockReturnValue({
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
hasUpdate: true,
})
renderSidebar()
await waitFor(() => {
const badge = screen.getByText('↑ v9.9.9 available').closest('a')
expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9')
expect(badge).toHaveAttribute('target', '_blank')
})
})
it('does not show update badge when hasUpdate is false even if latest exists', () => {
vi.mocked(useLatestRelease).mockReturnValue({
latest: { version: __APP_VERSION__, url: 'https://github.com' },
hasUpdate: false,
})
renderSidebar()
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
})
})
+16 -50
View File
@@ -1,64 +1,30 @@
"use client" import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
import { cn } from "@/lib/utils" const TooltipTrigger = TooltipPrimitive.Trigger
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({ function TooltipContent({
className, className,
side = "top",
sideOffset = 4, sideOffset = 4,
align = "center",
alignOffset = 0,
children, children,
...props ...props
}: TooltipPrimitive.Popup.Props & }: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner <TooltipPrimitive.Content
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
className="isolate z-50" 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}
> >
<TooltipPrimitive.Popup {children}
data-slot="tooltip-content" </TooltipPrimitive.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> </TooltipPrimitive.Portal>
) )
} }
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
// Reset module between tests so the module-level cache is cleared
async function freshHook() {
vi.resetModules()
const mod = await import('../useLatestRelease')
return mod.useLatestRelease
}
const CURRENT = '1.8.3'
function mockFetch(payload: unknown, ok = true) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
json: () => Promise.resolve(payload),
}),
)
}
describe('useLatestRelease', () => {
beforeEach(() => {
vi.unstubAllGlobals()
})
it('returns no update when latest version matches current', async () => {
mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => expect(result.current.latest).not.toBeNull())
expect(result.current.hasUpdate).toBe(false)
})
it('returns update when latest version is newer', async () => {
mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => expect(result.current.hasUpdate).toBe(true))
expect(result.current.latest?.version).toBe('1.9.0')
expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0')
})
it('strips leading v from tag_name', async () => {
mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => expect(result.current.latest).not.toBeNull())
expect(result.current.latest?.version).toBe('2.0.0')
})
it('does not show update when API returns non-ok response', async () => {
mockFetch({ message: 'Not Found' }, false)
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
expect(result.current.latest).toBeNull()
})
it('does not show update when API returns missing tag_name', async () => {
mockFetch({ html_url: 'https://github.com/example' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
})
it('does not show update when API returns missing html_url', async () => {
mockFetch({ tag_name: 'v2.0.0' })
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
})
it('does not show update when fetch throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
const useLatestRelease = await freshHook()
const { result } = renderHook(() => useLatestRelease(CURRENT))
await new Promise((r) => setTimeout(r, 50))
expect(result.current.hasUpdate).toBe(false)
})
it('fetches only once when hook is mounted multiple times concurrently', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }),
})
vi.stubGlobal('fetch', fetchMock)
const useLatestRelease = await freshHook()
// Mount all three before the fetch resolves — cache is set to 'pending' after first mount
const a = renderHook(() => useLatestRelease(CURRENT))
const b = renderHook(() => useLatestRelease(CURRENT))
const c = renderHook(() => useLatestRelease(CURRENT))
await waitFor(() => {
expect(a.result.current.latest).not.toBeNull()
})
expect(fetchMock).toHaveBeenCalledTimes(1)
// All hooks see the same result once cache resolves
expect(b.result.current.hasUpdate).toBe(false)
expect(c.result.current.hasUpdate).toBe(false)
})
})
@@ -0,0 +1,170 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useStatusPolling } from '../useStatusPolling'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockUpdateNode = vi.fn()
const mockNotifyScanDeviceFound = vi.fn()
class MockWebSocket {
static instances: MockWebSocket[] = []
url: string
onopen: (() => void) | null = null
onmessage: ((e: { data: string }) => void) | null = null
onerror: ((e: unknown) => void) | null = null
send = vi.fn()
close = vi.fn()
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
}
describe('useStatusPolling', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
vi.mocked(useCanvasStore).mockReturnValue({
updateNode: mockUpdateNode,
notifyScanDeviceFound: mockNotifyScanDeviceFound,
} as ReturnType<typeof useCanvasStore>)
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
token: 'test-token',
} as ReturnType<typeof useAuthStore>)
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'localhost:5173' },
writable: true,
})
})
afterEach(() => {
vi.restoreAllMocks()
mockUpdateNode.mockClear()
mockNotifyScanDeviceFound.mockClear()
})
it('does not open WebSocket when not authenticated', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: false,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('does not open WebSocket when token is missing', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('connects to correct ws:// URL', () => {
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
expect(MockWebSocket.instances[0].url).toBe('ws://localhost:5173/api/v1/status/ws/status')
})
it('uses wss:// when page is served over https', () => {
Object.defineProperty(window, 'location', {
value: { protocol: 'https:', host: 'mylab.local' },
writable: true,
})
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//)
})
it('sends token as first message on open', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onopen?.()
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
})
it('calls updateNode with correct data on status message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
status: 'online',
checked_at: '2024-01-01T12:00:00Z',
response_time_ms: 42,
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
status: 'online',
response_time_ms: 42,
last_seen: '2024-01-01T12:00:00Z',
})
})
it('sets last_seen to undefined when status is offline', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
status: 'offline',
checked_at: '2024-01-01T12:00:00Z',
}),
})
expect(mockUpdateNode).toHaveBeenCalledWith('node-1', {
status: 'offline',
response_time_ms: undefined,
last_seen: undefined,
})
})
it('sets response_time_ms to undefined when null in message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
})
expect(mockUpdateNode).toHaveBeenCalledWith(
'node-1',
expect.objectContaining({ response_time_ms: undefined }),
)
})
it('calls notifyScanDeviceFound on scan_device_found message', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ type: 'scan_device_found' }) })
expect(mockNotifyScanDeviceFound).toHaveBeenCalledOnce()
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores malformed JSON without throwing', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
expect(() => ws.onmessage?.({ data: 'not-valid-json{{' })).not.toThrow()
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('ignores messages with no node_id or status', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({ data: JSON.stringify({ some: 'unknown-field' }) })
expect(mockUpdateNode).not.toHaveBeenCalled()
})
it('closes WebSocket on unmount', () => {
const { unmount } = renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
unmount()
expect(ws.close).toHaveBeenCalledOnce()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
interface ReleaseInfo {
version: string
url: string
}
let cache: ReleaseInfo | null | 'error' | 'pending' = null
export function useLatestRelease(currentVersion: string) {
const [latest, setLatest] = useState<ReleaseInfo | null>(
cache && cache !== 'error' && cache !== 'pending' ? cache : null,
)
useEffect(() => {
if (cache !== null) return
cache = 'pending'
fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', {
headers: { Accept: 'application/vnd.github+json' },
})
.then((res) => {
if (!res.ok) { cache = 'error'; return }
return res.json()
})
.then((data) => {
if (!data || typeof data.tag_name !== 'string' || !data.html_url) {
cache = 'error'
return
}
const version = data.tag_name.replace(/^v/, '')
const info: ReleaseInfo = { version, url: data.html_url }
cache = info
setLatest(info)
})
.catch(() => {
cache = 'error'
})
}, [])
const hasUpdate = latest !== null && latest.version !== currentVersion
return { latest, hasUpdate }
}
+14
View File
@@ -6,6 +6,11 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@keyframes homelable-basic-dash {
from { stroke-dashoffset: 10; }
to { stroke-dashoffset: 0; }
}
/* Homelable dark theme — always dark */ /* Homelable dark theme — always dark */
:root { :root {
--background: #0d1117; --background: #0d1117;
@@ -111,6 +116,15 @@
background-color: var(--surface-card) !important; background-color: var(--surface-card) !important;
} }
/* Transparent wrapper for container node types */
.react-flow__node-proxmox,
.react-flow__node-group {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
}
/* Mono font utility */ /* Mono font utility */
.font-mono { .font-mono {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
past: [], past: [],
future: [], future: [],
@@ -48,6 +49,32 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true) expect(hasUnsavedChanges).toBe(true)
}) })
it('addNode nests under parent only when parent is in container mode', () => {
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } }
useCanvasStore.getState().addNode(parent)
useCanvasStore.getState().addNode(child)
const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(childNode?.parentId).toBeUndefined()
useCanvasStore.getState().updateNode('p1', { container_mode: true })
useCanvasStore.getState().setProxmoxContainerMode('p1', true)
const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(nested?.parentId).toBe('p1')
expect(nested?.extent).toBe('parent')
})
it('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', () => { it('updateNode updates data fields', () => {
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' })) useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' }) useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
@@ -56,6 +83,57 @@ describe('canvasStore', () => {
expect(node?.data.ip).toBe('10.0.0.1') expect(node?.data.ip).toBe('10.0.0.1')
}) })
it('updateNode setting parent_id on container-mode proxmox sets parentId and relative position', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
expect(node?.parentId).toBe('px1')
expect(node?.extent).toBe('parent')
// Position should be relative to parent (160-100=60, 180-100=80)
expect(node?.position.x).toBe(60)
expect(node?.position.y).toBe(80)
})
it('updateNode setting parent_id on non-container proxmox does NOT set React Flow parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
expect(node?.parentId).toBeUndefined()
expect(node?.extent).toBeUndefined()
})
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'lxc1')
expect(node?.parentId).toBeUndefined()
expect(node?.extent).toBeUndefined()
// Position should be absolute (100+30=130, 100+40=140)
expect(node?.position.x).toBe(130)
expect(node?.position.y).toBe(140)
})
it('updateNode with parent_id puts parents before children in array', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 10, y: 10 } }
useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: 'px1' })
const { nodes } = useCanvasStore.getState()
const pxIdx = nodes.findIndex((n) => n.id === 'px1')
const lxcIdx = nodes.findIndex((n) => n.id === 'lxc1')
expect(pxIdx).toBeLessThan(lxcIdx)
})
it('deleteNode removes node and its connected edges', () => { it('deleteNode removes node and its connected edges', () => {
const store = useCanvasStore.getState() const store = useCanvasStore.getState()
store.addNode(makeNode('n1')) store.addNode(makeNode('n1'))
@@ -162,7 +240,7 @@ describe('canvasStore', () => {
}) })
it('deleteNode also removes children with matching parentId', () => { it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent') useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState() const { nodes } = useCanvasStore.getState()
@@ -170,14 +248,186 @@ describe('canvasStore', () => {
expect(nodes.find((n) => n.id === 'child')).toBeUndefined() expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
}) })
it('addNode with parent_id sets parentId and extent', () => { it('addNode with parent_id sets parentId and extent when parent is in container mode', () => {
useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child') const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
expect(child?.parentId).toBe('parent') expect(child?.parentId).toBe('parent')
expect(child?.extent).toBe('parent') expect(child?.extent).toBe('parent')
}) })
// ── selectedNodeIds ───────────────────────────────────────────────────────
it('selectedNodeIds starts empty', () => {
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('onNodesChange syncs selectedNodeIds from select changes', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().addNode(makeNode('n2'))
useCanvasStore.getState().onNodesChange([
{ type: 'select', id: 'n1', selected: true },
{ type: 'select', id: 'n2', selected: true },
])
expect(useCanvasStore.getState().selectedNodeIds).toEqual(expect.arrayContaining(['n1', 'n2']))
expect(useCanvasStore.getState().selectedNodeIds).toHaveLength(2)
})
it('setSelectedNode(null) resets selectedNodeIds to empty', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode(null)
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
it('setSelectedNode(id) sets selectedNodeIds to [id], clearing multi-selection', () => {
useCanvasStore.setState({ selectedNodeIds: ['n1', 'n2'] })
useCanvasStore.getState().setSelectedNode('n1')
// Single node click resets multi-selection to just the clicked node
expect(useCanvasStore.getState().selectedNodeIds).toEqual(['n1'])
})
// ── createGroup ───────────────────────────────────────────────────────────
it('createGroup creates a group node at the bounding box of selected nodes', () => {
// n1 at (100,100), n2 at (300,200); both default to 200x80
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'My Group')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')
expect(group).toBeDefined()
expect(group?.data.label).toBe('My Group')
// groupX = 100-24=76, groupY = 100-48=52
expect(group?.position.x).toBe(76)
expect(group?.position.y).toBe(52)
// groupW = (500-100)+48=448, groupH = (280-100)+48+24=252
expect(group?.width).toBe(448)
expect(group?.height).toBe(252)
})
it('createGroup converts children to relative positions', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 }, width: 200, height: 80 }
const n2 = { ...makeNode('n2'), position: { x: 300, y: 200 }, width: 200, height: 80 }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
// groupX=76, groupY=52 → relative: n1=(24,48), n2=(224,148)
expect(c1?.position).toEqual({ x: 24, y: 48 })
expect(c2?.position).toEqual({ x: 224, y: 148 })
})
it('createGroup sets parentId and extent on children', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const group = nodes.find((n) => n.data.type === 'group')!
const c1 = nodes.find((n) => n.id === 'n1')
const c2 = nodes.find((n) => n.id === 'n2')
expect(c1?.parentId).toBe(group.id)
expect(c1?.extent).toBe('parent')
expect(c2?.parentId).toBe(group.id)
})
it('createGroup places the group node before its children in the array', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
const n2 = { ...makeNode('n2'), position: { x: 200, y: 100 } }
useCanvasStore.setState({ nodes: [n1, n2] })
useCanvasStore.getState().createGroup(['n1', 'n2'], 'G')
const { nodes } = useCanvasStore.getState()
const groupIdx = nodes.findIndex((n) => n.data.type === 'group')
const c1Idx = nodes.findIndex((n) => n.id === 'n1')
const c2Idx = nodes.findIndex((n) => n.id === 'n2')
expect(groupIdx).toBeLessThan(c1Idx)
expect(groupIdx).toBeLessThan(c2Idx)
})
it('createGroup snapshots history and marks unsaved', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('createGroup clears selection', () => {
const n1 = { ...makeNode('n1'), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
useCanvasStore.getState().createGroup(['n1'], 'G')
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
expect(useCanvasStore.getState().selectedNodeIds).toEqual([])
})
// ── ungroup ───────────────────────────────────────────────────────────────
it('ungroup restores children to absolute positions', () => {
const group = {
...makeNode('g1', { type: 'group', label: 'G' }),
position: { x: 76, y: 52 },
}
const c1 = { ...makeNode('n1'), position: { x: 24, y: 48 }, parentId: 'g1', extent: 'parent' as const }
const c2 = { ...makeNode('n2'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, c1, c2] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const r1 = nodes.find((n) => n.id === 'n1')
const r2 = nodes.find((n) => n.id === 'n2')
expect(r1?.position).toEqual({ x: 100, y: 100 })
expect(r2?.position).toEqual({ x: 300, y: 200 })
})
it('ungroup removes parentId and extent from children', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
useCanvasStore.setState({ nodes: [group, child] })
useCanvasStore.getState().ungroup('g1')
const { nodes } = useCanvasStore.getState()
const released = nodes.find((n) => n.id === 'n1')
expect(released?.parentId).toBeUndefined()
expect(released?.extent).toBeUndefined()
})
it('ungroup deletes the group node', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'g1')).toBeUndefined()
})
it('ungroup snapshots history and marks unsaved', () => {
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().markSaved()
useCanvasStore.getState().ungroup('g1')
expect(useCanvasStore.getState().past).toHaveLength(1)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('updateEdge updates edge data and marks unsaved', () => { it('updateEdge updates edge data and marks unsaved', () => {
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] })) useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
useCanvasStore.getState().markSaved() useCanvasStore.getState().markSaved()
@@ -198,6 +448,26 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true) 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', () => { 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 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' }) const child = makeNode('vm1', { parent_id: 'px', type: 'vm' })
@@ -394,4 +664,61 @@ describe('canvasStore', () => {
expect(stored?.width).toBeUndefined() expect(stored?.width).toBeUndefined()
expect(stored?.height).toBeUndefined() expect(stored?.height).toBeUndefined()
}) })
// ── bottom_handles edge remapping ──────────────────────────────────────────
it('remaps source edges to "bottom" when bottom_handles is reduced', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
it('remaps target edges to "bottom" when bottom_handles is reduced', () => {
const node = makeNode('n2', { bottom_handles: 3 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), targetHandle: 'bottom-3' }
useCanvasStore.setState({ nodes: [makeNode('n1'), node], edges: [edge] })
useCanvasStore.getState().updateNode('n2', { bottom_handles: 1 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.targetHandle).toBe('bottom')
})
it('does not remap edges that are on handles still present after reduction', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-2' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 3 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom-2')
})
it('does not remap edges when bottom_handles increases', () => {
const node = makeNode('n1', { bottom_handles: 2 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 4 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
it('never remaps the "bottom" handle itself', () => {
const node = makeNode('n1', { bottom_handles: 4 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
}) })
+229 -19
View File
@@ -11,6 +11,7 @@ import {
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -19,6 +20,7 @@ interface CanvasState {
edges: Edge<EdgeData>[] edges: Edge<EdgeData>[]
hasUnsavedChanges: boolean hasUnsavedChanges: boolean
selectedNodeId: string | null selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number scanEventTs: number
// History // History
@@ -46,9 +48,13 @@ interface CanvasState {
setNodeZIndex: (id: string, zIndex: number) => void setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void setEditingGroupRectId: (id: string | null) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => void markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
@@ -59,9 +65,11 @@ export const useCanvasStore = create<CanvasState>((set) => ({
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null, editingGroupRectId: null,
hideIp: false, hideIp: false,
scanEventTs: 0, scanEventTs: 0,
fitViewPending: false,
past: [], past: [],
future: [], future: [],
@@ -125,10 +133,15 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}), }),
onNodesChange: (changes) => onNodesChange: (changes) =>
set((state) => ({ set((state) => {
nodes: applyNodeChanges(changes, state.nodes), const nodes = applyNodeChanges(changes, state.nodes)
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id)
})), return {
nodes,
selectedNodeIds,
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
}
}),
onEdgesChange: (changes) => onEdgesChange: (changes) =>
set((state) => ({ set((state) => ({
@@ -140,10 +153,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set((state) => { set((state) => {
const extra = connection as Connection & Partial<EdgeData> const extra = connection as Connection & Partial<EdgeData>
const edgeType = extra.type ?? 'ethernet' const edgeType = extra.type ?? 'ethernet'
// Normalize invisible stub handle IDs so React Flow can locate the handle
// and render the edge immediately (top-t / bottom-t are opacity:0 helpers).
const normalizeHandle = (h: string | null | undefined) =>
h === 'top-t' ? 'top' : h === 'bottom-t' ? 'bottom' : (h ?? null)
return { return {
edges: addEdge({ edges: addEdge({
...connection, ...connection,
@@ -156,12 +165,25 @@ export const useCanvasStore = create<CanvasState>((set) => ({
} }
}), }),
setSelectedNode: (id) => set({ selectedNodeId: id }), setSelectedNode: (id) => set({
selectedNodeId: id,
selectedNodeIds: id ? [id] : [],
}),
addNode: (node) => addNode: (node) =>
set((state) => { set((state) => {
const enriched = node.data.parent_id const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } const shouldNestInParent = !!(parent?.data.container_mode)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
},
}
: node : node
// Parents must come before children in the array (React Flow requirement) // Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id) const withoutNew = state.nodes.filter((n) => n.id !== node.id)
@@ -175,12 +197,69 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}), }),
updateNode: (id, data) => updateNode: (id, data) =>
set((state) => ({ set((state) => {
nodes: state.nodes.map((n) => let nodes = state.nodes.map((n) => {
n.id === id ? { ...n, data: { ...n.data, ...data } } : n if (n.id !== id) return n
), const updated: Node<NodeData> = { ...n, data: { ...n.data, ...data } }
hasUnsavedChanges: true, // When properties change, clear stored height so the node auto-sizes to fit new content
})), if ('properties' in data && n.data.type !== 'proxmox' && n.data.type !== 'groupRect') {
updated.height = undefined
}
if ('parent_id' in data) {
const newParentId = data.parent_id ?? undefined
if (!newParentId && n.parentId) {
// Detaching from a container: convert position back to absolute canvas coords
const parent = state.nodes.find((p) => p.id === n.parentId)
if (parent) {
updated.position = {
x: parent.position.x + n.position.x,
y: parent.position.y + n.position.y,
}
}
updated.parentId = undefined
updated.extent = undefined
} else if (newParentId && newParentId !== n.parentId) {
const parent = state.nodes.find((p) => p.id === newParentId)
if (parent?.data.container_mode) {
// Attaching to a container-mode Proxmox: nest visually
updated.parentId = newParentId
updated.extent = 'parent' as const
// Convert absolute position to parent-relative (keep node visible inside)
updated.position = {
x: Math.max(10, n.position.x - parent.position.x),
y: Math.max(10, n.position.y - parent.position.y),
}
}
}
}
return updated
})
// React Flow requires parent nodes to precede their children in the array
if ('parent_id' in data) {
const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children]
}
// Remap edges when bottom_handles is reduced so no edge disappears
let edges = state.edges
if ('bottom_handles' in data && data.bottom_handles != null) {
const currentNode = state.nodes.find((n) => n.id === id)
const oldCount = currentNode?.data.bottom_handles ?? 1
const newCount = data.bottom_handles
if (newCount < oldCount) {
const removed = removedBottomHandleIds(oldCount, newCount)
edges = state.edges.map((e) => {
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
return { ...e, sourceHandle: 'bottom' }
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
return { ...e, targetHandle: 'bottom' }
return e
})
}
}
return { nodes, edges, hasUnsavedChanges: true }
}),
deleteNode: (id) => deleteNode: (id) =>
set((state) => { set((state) => {
@@ -214,14 +293,37 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setProxmoxContainerMode: (proxmoxId, enabled) => setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => { set((state) => {
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
let nodes = state.nodes.map((n) => { let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) { if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } } const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
return enabled return enabled
? { ...withMode, width: 300, height: 200 } ? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
: { ...withMode, width: undefined, height: undefined } : { ...withMode, width: undefined, height: undefined }
} }
if (n.data.parent_id === proxmoxId) { if (n.data.parent_id === proxmoxId) {
if (enabled && parentNode) {
return {
...n,
parentId: proxmoxId,
extent: 'parent' as const,
position: {
x: Math.max(10, n.position.x - parentNode.position.x),
y: Math.max(10, n.position.y - parentNode.position.y),
},
}
}
if (!enabled && parentNode) {
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: parentNode.position.x + n.position.x,
y: parentNode.position.y + n.position.y,
},
}
}
return enabled return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const } ? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined } : { ...n, parentId: undefined, extent: undefined }
@@ -244,6 +346,112 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }), setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
const PADDING_TOP = 48
const PADDING_BOTTOM = 24
const targets = state.nodes.filter((n) => nodeIds.includes(n.id))
if (targets.length === 0) return state
// Bounding box in absolute coordinates
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of targets) {
const w = n.width ?? 200
const h = n.height ?? 80
minX = Math.min(minX, n.position.x)
minY = Math.min(minY, n.position.y)
maxX = Math.max(maxX, n.position.x + w)
maxY = Math.max(maxY, n.position.y + h)
}
const groupX = minX - PADDING_H
const groupY = minY - PADDING_TOP
const groupW = maxX - minX + PADDING_H * 2
const groupH = maxY - minY + PADDING_TOP + PADDING_BOTTOM
const groupId = generateUUID()
const groupNode: Node<NodeData> = {
id: groupId,
type: 'group',
position: { x: groupX, y: groupY },
width: groupW,
height: groupH,
data: {
label: name,
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
},
selected: false,
}
// Convert children to relative positions and assign parentId
const updatedNodes = state.nodes.map((n) => {
if (!nodeIds.includes(n.id)) return n
return {
...n,
parentId: groupId,
extent: 'parent' as const,
position: {
x: n.position.x - groupX,
y: n.position.y - groupY,
},
selected: false,
data: { ...n.data, parent_id: groupId },
}
})
// Group node must come before its children
const withoutTargets = updatedNodes.filter((n) => !nodeIds.includes(n.id))
const children = updatedNodes.filter((n) => nodeIds.includes(n.id))
const nodes = [...withoutTargets, groupNode, ...children]
return {
nodes,
selectedNodeIds: [],
selectedNodeId: null,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
ungroup: (groupId) =>
set((state) => {
const group = state.nodes.find((n) => n.id === groupId)
if (!group) return state
const groupAbsX = group.position.x
const groupAbsY = group.position.y
const nodes = state.nodes
.filter((n) => n.id !== groupId)
.map((n) => {
if (n.parentId !== groupId) return n
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: n.position.x + groupAbsX,
y: n.position.y + groupAbsY,
},
data: { ...n.data, parent_id: undefined },
}
})
return {
nodes,
selectedNodeId: null,
selectedNodeIds: [],
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }), markUnsaved: () => set({ hasUnsavedChanges: true }),
@@ -256,6 +464,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
// React Flow requires parents before children in the array // React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId) const parents = nodes.filter((n) => !n.parentId)
const children = nodes.filter((n) => !!n.parentId) const children = nodes.filter((n) => !!n.parentId)
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] }) set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
}, },
clearFitViewPending: () => set({ fitViewPending: false }),
})) }))
+25 -4
View File
@@ -13,9 +13,11 @@ export type NodeType =
| 'printer' | 'printer'
| 'computer' | 'computer'
| 'cpl' | 'cpl'
| 'docker' | 'docker_host'
| 'docker_container'
| 'generic' | 'generic'
| 'groupRect' | 'groupRect'
| 'group'
export type TextPosition = export type TextPosition =
| 'top-left' | 'top-left'
@@ -35,13 +37,21 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none' export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
export interface ServiceInfo { export interface ServiceInfo {
port: number port?: number
protocol: 'tcp' | 'udp' protocol: 'tcp' | 'udp'
service_name: string service_name: string
path?: string
icon?: string icon?: string
category?: string category?: string
} }
export interface NodeProperty {
key: string
value: string
icon: string | null
visible: boolean
}
export interface NodeData extends Record<string, unknown> { export interface NodeData extends Record<string, unknown> {
label: string label: string
type: NodeType type: NodeType
@@ -61,6 +71,7 @@ export interface NodeData extends Record<string, unknown> {
ram_gb?: number ram_gb?: number
disk_gb?: number disk_gb?: number
show_hardware?: boolean show_hardware?: boolean
properties?: NodeProperty[]
parent_id?: string parent_id?: string
container_mode?: boolean container_mode?: boolean
custom_colors?: { custom_colors?: {
@@ -76,14 +87,21 @@ export interface NodeData extends Record<string, unknown> {
label_position?: 'inside' | 'outside' label_position?: 'inside' | 'outside'
text_size?: number text_size?: number
z_order?: number z_order?: number
show_border?: boolean
width?: number width?: number
height?: number height?: number
} }
custom_icon?: string custom_icon?: string
bottom_handles?: number
} }
export type EdgePathStyle = 'bezier' | 'smooth' export type EdgePathStyle = 'bezier' | 'smooth'
export interface Waypoint {
x: number
y: number
}
export interface EdgeData extends Record<string, unknown> { export interface EdgeData extends Record<string, unknown> {
type: EdgeType type: EdgeType
label?: string label?: string
@@ -91,7 +109,8 @@ export interface EdgeData extends Record<string, unknown> {
speed?: string speed?: string
custom_color?: string custom_color?: string
path_style?: EdgePathStyle path_style?: EdgePathStyle
animated?: boolean | 'snake' | 'flow' | 'none' animated?: boolean | 'snake' | 'flow' | 'basic' | 'none'
waypoints?: Waypoint[]
} }
export const NODE_TYPE_LABELS: Record<NodeType, string> = { export const NODE_TYPE_LABELS: Record<NodeType, string> = {
@@ -109,9 +128,11 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer', printer: 'Printer',
computer: 'Computer', computer: 'Computer',
cpl: 'CPL / Powerline', cpl: 'CPL / Powerline',
docker: 'Docker Host', docker_host: 'Docker Host',
docker_container: 'Docker Container',
generic: 'Generic Device', generic: 'Generic Device',
groupRect: 'Group Rectangle', groupRect: 'Group Rectangle',
group: 'Node Group',
} }
export const STATUS_COLORS: Record<NodeStatus, string> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
@@ -236,6 +236,36 @@ describe('serializeEdge', () => {
expect(result.custom_color).toBeNull() expect(result.custom_color).toBeNull()
expect(result.path_style).toBeNull() expect(result.path_style).toBeNull()
}) })
it('serializes waypoints when present', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [{ x: 10, y: 20 }, { x: 30, y: 40 }] } })
const result = serializeEdge(edge)
expect(result.waypoints).toEqual([{ x: 10, y: 20 }, { x: 30, y: 40 }])
})
it('serializes waypoints as null when empty array', () => {
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [] } })
const result = serializeEdge(edge)
expect(result.waypoints).toBeNull()
})
it('serializes waypoints as null when absent', () => {
const result = serializeEdge(makeRfEdge())
expect(result.waypoints).toBeNull()
})
})
describe('deserializeApiEdge — waypoints', () => {
it('restores waypoints from API edge', () => {
const edge = makeApiEdge({ waypoints: [{ x: 5, y: 15 }, { x: 25, y: 35 }] })
const result = deserializeApiEdge(edge)
expect((result.data as { waypoints: unknown }).waypoints).toEqual([{ x: 5, y: 15 }, { x: 25, y: 35 }])
})
it('has no waypoints when API edge has none', () => {
const result = deserializeApiEdge(makeApiEdge())
expect((result.data as { waypoints?: unknown }).waypoints).toBeUndefined()
})
}) })
// ── deserializeApiNode — regular nodes ─────────────────────────────────────── // ── deserializeApiNode — regular nodes ───────────────────────────────────────
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { hexToRgba, rgbaToHex8 } from '../colorUtils'
describe('hexToRgba', () => {
it('splits 8-digit hex into hex6 and alpha', () => {
const { hex6, alpha } = hexToRgba('#00d4ff0d')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(5)
})
it('handles fully opaque 8-digit hex (ff)', () => {
const { hex6, alpha } = hexToRgba('#00d4ffff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles fully transparent 8-digit hex (00)', () => {
const { hex6, alpha } = hexToRgba('#00d4ff00')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(0)
})
it('defaults alpha to 100 for 6-digit hex', () => {
const { hex6, alpha } = hexToRgba('#00d4ff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles 6-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e00')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(100)
})
it('handles 8-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e0080')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(50)
})
it('returns fallback for invalid input', () => {
const { hex6, alpha } = hexToRgba('invalid')
expect(hex6).toBe('#000000')
expect(alpha).toBe(100)
})
it('is case-insensitive', () => {
const { hex6 } = hexToRgba('#00D4FF0D')
expect(hex6).toBe('#00D4FF')
})
})
describe('rgbaToHex8', () => {
it('combines hex6 and alpha into 8-digit hex', () => {
expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
})
it('produces ff for alpha 100', () => {
expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
})
it('produces 00 for alpha 0', () => {
expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
})
it('produces 80 for alpha 50', () => {
expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
})
it('clamps alpha below 0 to 0', () => {
expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
})
it('clamps alpha above 100 to 100', () => {
expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
})
it('pads single-digit alpha hex with leading zero', () => {
const result = rgbaToHex8('#000000', 1)
const alphaPart = result.slice(7)
expect(alphaPart.length).toBe(2)
})
})
describe('round-trip', () => {
it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
const original = '#00d4ff0d'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
it('round-trips fully opaque color', () => {
const original = '#a855f7ff'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
})

Some files were not shown because too many files have changed in this diff Show More