Compare commits

...

208 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
Pouzor 49963c79f7 chore: bump version to 1.5.0 2026-03-29 19:35:08 +02:00
Pouzor ea539d6e31 fix: null guards, aria-labels, and missing tests for DetailPanel
- Extract const services = data.services ?? [] for consistent null safety
- Add aria-label to close and delete buttons
- Add tests: close, edit callback, delete (confirm/cancel), add service, remove service, undefined services
2026-03-29 19:28:52 +02:00
Pouzor f657e45995 fix: null guard data.services and aria-label on delete button
Fixes crash when services is undefined on legacy nodes.
Adds aria-label="Delete node" for accessibility.
2026-03-29 19:25:55 +02:00
Pouzor 3fe9fa7ca8 feat: add inline edit for services in detail panel
Replaces service badge with in-place form when pencil icon is clicked.
State is scoped to nodeId so switching nodes auto-resets edit/add forms.
2026-03-29 19:24:22 +02:00
Pouzor f0222247bb feat: add inline edit for services in detail panel 2026-03-29 19:19:29 +02:00
Pouzor 9c92d39629 fix: return 401 (not 500) when bcrypt hash is malformed (#21)
- verify_password catches ValueError from passlib so a mangled hash
  ($ signs stripped by shell/Docker) returns False instead of crashing
- Settings.check_password_hash logs a clear startup error with fix
  instructions when AUTH_PASSWORD_HASH doesn't start with '$2'
2026-03-29 16:03:40 +02:00
Pouzor e4c0d820f4 fix: render snake vs flow edge animations correctly
edges/index.tsx was never committed — both animation modes were
rendering as snake (truthy string check). Now uses animMode to
distinguish 'snake' (moving blob) from 'flow' (continuous flowing dashes).
2026-03-29 15:18:19 +02:00
Remy f9c8e37de3 Merge pull request #22 from Pouzor/feat/front-improvement
feat: Zone improvements + edge animation modes
2026-03-29 15:07:53 +02:00
Pouzor 7ed6b77165 fix: update EdgeModal tests for None/Snake/Flow animation selector 2026-03-29 15:01:20 +02:00
Pouzor 95a3db34f1 fix: move AnimMode type to module scope, fix tsc -b build error 2026-03-29 14:57:25 +02:00
Pouzor 37cb97dca1 fix: add border_width, text_size, label_position to custom_colors type 2026-03-29 14:46:23 +02:00
Pouzor 32b60a201b fix: persist edge animation mode (None/Snake/Flow) end-to-end
- canvasStore.onConnect: include animated in edge data object (was silently dropped)
- Backend schemas: normalize animated bool/int to string ('none'/'snake'/'flow') via field_validator
- ORM model: change animated column from Boolean to String
- DB migration: convert existing 0/1 boolean rows to 'none'/'snake' strings
2026-03-29 14:41:18 +02:00
Pouzor 4ccdbed711 feat: add label position (inside/outside) and text size to Zone modal
- Label position toggle: inside (default) or outside the border
- Outside mode renders the label above/below the zone based on text_position
- Text size selector: 10/12/14/16/18/20px (default 12)
- Both fields persisted in custom_colors (no backend schema change needed)
- 8 new frontend tests, 1 new backend test
2026-03-29 03:04:29 +02:00
Pouzor 38a06682e5 feat: rename Rectangle to Zone, add border width selector
- Rename "Rectangle" → "Zone" in sidebar, add modal and edit modal
- Add border width selector (1–5px, default 2px) to the Zone modal
- Border width persisted in custom_colors.border_width and applied in GroupRectNode
2026-03-29 01:47:04 +01:00
Remy 900cc62b27 Update README.md 2026-03-28 18:37:57 +01:00
Pouzor 343249fbcd fix: update login test to use http error object after network/auth error distinction 2026-03-28 18:30:50 +01:00
Pouzor 4aca82fb1a fix: remove hardcoded CORS_ORIGINS from docker-compose, improve login errors
CORS_ORIGINS was hardcoded in docker-compose.yml, silently overriding .env
and breaking login for users who change the frontend port. It now comes
from .env exclusively, with a clear comment in .env.example.

Login page now distinguishes network errors (CORS/offline) from wrong
credentials, and footer correctly references .env instead of config.yml.
2026-03-28 18:27:54 +01:00
Pouzor bd047e594e fix: restore package-lock.json — revert parseurl/tiny-invariant version corruption from sed bump 2026-03-28 18:08:04 +01:00
Pouzor 61b30a95fe chore: bump version to 1.4.0 2026-03-28 17:59:25 +01:00
Remy 0b97b7127a Merge pull request #14 from Pouzor/feat/liveview
feat: read-only live view at /view?key=<LIVEVIEW_KEY>
2026-03-28 17:53:44 +01:00
Pouzor 2ce942ae61 Update readme 2026-03-28 16:59:33 +01:00
Pouzor 5897be70c2 fix: timing-safe key comparison and network-error state in liveview
Use hmac.compare_digest() to prevent timing-based key enumeration.
Distinguish network failures from invalid-key errors in the frontend.
2026-03-28 15:30:09 +01:00
Pouzor 210304394e feat: read-only live view at /view?key=<LIVEVIEW_KEY>
Implements issue #5. Off by default; set LIVEVIEW_KEY in .env to enable.
No JWT required — key-based auth via ?key= query param.
Returns 403 when disabled or key is wrong.
Read-only ReactFlow canvas (pan/zoom, no editing).
Standalone mode loads from localStorage without a key.
Includes 8 backend tests and 9 frontend tests.
2026-03-28 15:27:54 +01:00
Pouzor b35f34ae73 fix: force frontend builder stage to native platform, fixes QEMU arm64 npm crash 2026-03-28 14:23:56 +01:00
Remy d84692fe4f Merge pull request #13 from Pouzor/feat/resizable-nodes
Feat/resizable nodes + tests
2026-03-28 14:12:23 +01:00
Pouzor d9f3477780 fix(ci): inject bcrypt hash via compose environment with $$ escaping, remove dynamic hash generation 2026-03-28 13:03:28 +01:00
Pouzor c9d6642b26 ci: pass DL3008 ignore directly to hadolint action 2026-03-28 12:58:03 +01:00
Pouzor c9e142dbf2 ci: suppress DL3008 hadolint warning (apt version pinning impractical for system tools) 2026-03-28 12:56:11 +01:00
Pouzor 57829d88e5 fix(install): restore nginx reload-or-start fallback broken by shellcheck fix 2026-03-28 12:53:50 +01:00
Pouzor fd8735ce7f fix(ci): quote CTID path, fix bcrypt hash shell expansion, npm audit fix 2026-03-28 12:49:50 +01:00
Pouzor 0bdf835a3d fix(ci): expose backend port in CI, fix shellcheck warnings in lxc-install.sh 2026-03-28 12:45:01 +01:00
Pouzor ae29d2c8f5 fix(tests): correct integration test API shapes and test isolation 2026-03-28 12:36:28 +01:00
Pouzor cc68fcf1c1 test: add deployment test tiers (shellcheck, hadolint, Docker smoke, integration)
Tier 1 — quality.yml: ShellCheck on lxc-install.sh, hadolint on both Dockerfiles
Tier 2 — docker-ci.yml: build images, smoke-test backend health + frontend 200
Tier 3 — test_integration.py: full stack pytest (auth, canvas save/reload, dimensions)
Also adds Docker healthcheck to backend service in docker-compose.yml
2026-03-28 12:33:35 +01:00
Pouzor 4cb164241a test: add standalone mode save/load and demo data validation tests 2026-03-28 12:26:48 +01:00
Pouzor b35b51d5b2 test: add component tests for LoginPage, modals, and CanvasContainer 2026-03-28 12:21:21 +01:00
Pouzor 565f4337c8 fix: extract canvas serializer and add full coverage including width/height 2026-03-28 12:05:54 +01:00
Pouzor 2a9cbc5932 feat: resizable nodes with width/height persistence
Add NodeResizer to BaseNode so users can drag corners to resize any node.
Persist width/height through the full stack: DB model, schemas, canvas
save/load route, and migration for existing databases.

Add tests covering save, update, clear, and load of node dimensions.
2026-03-28 11:57:09 +01:00
Remy 52cc5cf666 Merge pull request #10 from ki4hrg/patch-1
Add VLAN_TAG variable with conditional VLAN tag support in interface creation
2026-03-28 01:30:19 +01:00
John Fleischauer 4643aabe28 Update network configuration to include VLAN tag 2026-03-27 13:17:36 -05:00
Pouzor 09b5317a0c fix: thread-safe signature loading, clear missing-file error, restrict CORS methods 2026-03-27 18:01:43 +01:00
Pouzor 0f643477f6 fix: move service_signatures.json inside app package and bump to 1.3.3
The file was in /app/data/ which gets overwritten by the Docker volume
mount (backend_data:/app/data), making it invisible at runtime and
causing scan failures on fresh installs. Moved to /app/app/data/ so
it stays baked into the image.
2026-03-27 17:54:39 +01:00
Pouzor 861d2822b9 fix: add iputils-ping to Docker image and bump version to 1.3.2
ping was missing from python:3.13-slim — ping check method always
returned offline on fresh Docker installs.
2026-03-27 14:59:58 +01:00
Remy 059bb3daa7 Update README.md 2026-03-27 12:28:12 +01:00
Pouzor c01d87381d add one more screenshot for the edit/show pannel 2026-03-27 12:27:17 +01:00
Pouzor ec0519d2b7 chore: bump version to 1.3.1 2026-03-27 11:26:26 +01:00
Pouzor 1182dbd82d Update first screenshot 2026-03-27 11:25:21 +01:00
Pouzor 821e324111 fix: downgrade eslint to ^9.x — eslint-plugin-react-hooks incompatible with eslint 10 2026-03-27 09:26:54 +01:00
Pouzor ea3adc0f94 update dependency 2026-03-27 09:21:46 +01:00
Pouzor 6f8f0d5e8f docs: split README — move installation to INSTALLATION.md, add network scanner intro, remove proxmox nested nodes section 2026-03-27 09:19:22 +01:00
Pouzor daf3f59590 ui: swap icons between Export and PNG buttons 2026-03-24 01:35:39 +01:00
Pouzor 212eb37e34 ui: place Export next to Import, swap PNG/Export icons 2026-03-24 01:34:13 +01:00
Pouzor 61b8a210fe ui: rename toolbar buttons — YAML export to Export, PNG export to PNG 2026-03-24 01:31:22 +01:00
Pouzor a43ffb813e fix: export all edges via links array — clusterR/clusterL reserved for cluster-type edges only 2026-03-24 01:25:26 +01:00
Remy f469d6c744 Merge pull request #4 from Pouzor/feat/yaml-import
feat: YAML import
2026-03-24 01:18:56 +01:00
Pouzor e7ab9a1d7a feat: add YAML import — merge nodes/edges from .yaml file into canvas 2026-03-24 01:14:54 +01:00
Remy d5b67a770c Merge pull request #3 from Pouzor/feat/yaml-export
feat: YAML export
2026-03-24 01:12:05 +01:00
Pouzor 2e49c14028 feat: add YAML export — serialize canvas nodes/edges to downloadable .yaml file 2026-03-23 21:59:04 +01:00
Pouzor d9787fdcbb security issue 2026-03-21 01:09:06 +01:00
Pouzor 06ec18a137 feat: add hardware specs to nodes (CPU, RAM, Disk) with canvas display
- Add cpu_count, cpu_model, ram_gb, disk_gb, show_hardware fields to NodeData
- NodeModal: collapsible Hardware section with toggle to show specs on canvas
- BaseNode: 2-line hardware section with Cpu/MemoryStick/HardDrive icons, only renders set fields
- DetailPanel: Hardware section with GB/TB formatting
- Backend: schema, model, DB migration, canvas save/load support
- Tests: frontend (NodeModal, DetailPanel) + backend (canvas persist/load)
2026-03-21 00:01:15 +01:00
Pouzor adb4474687 feat: add Docker node type, group rect border style, and categorized node type selector
- Fix DockerNode default icon: use Anchor instead of Package
- Add border_style option to Group Rectangle (solid/dashed/dotted/double/none)
- Group node type selector by category (Hardware, Virtualization, IoT, Generic)
- Add tests for border style feature
2026-03-20 21:09:58 +01:00
Pouzor 2008f9467a fix: exclude test files from tsconfig.app.json to fix production build 2026-03-18 00:56:33 +01:00
Pouzor d9ac9462a8 feat: add update script for LXC installs and document update procedure 2026-03-18 00:52:43 +01:00
Pouzor e14a9e87aa fix: stop exposing JWT in WebSocket URL query param
Token was visible in server logs, browser history, and proxy access logs.
Backend now accepts the connection first, then validates a JSON auth
message {"token": "<jwt>"} sent by the client on open before adding
the socket to the active connections pool.
2026-03-18 00:49:03 +01:00
Pouzor e5d7260696 fix: resolve WebSocket failure and crypto.randomUUID crash on HTTP/LXC
- Replace crypto.randomUUID() with a polyfill (generateUUID) that falls
  back to crypto.getRandomValues or Math.random — fixes crash on HTTP
  non-secure contexts where randomUUID is unavailable
- Fix WebSocket URL hardcoding port 8000 — use window.location.host so
  connections go through Nginx proxy in Docker/LXC instead of bypassing it
- Add /api/v1/status/ws/ location block in nginx.conf with WebSocket
  upgrade headers (must precede /api/ to avoid missing Upgrade header)
2026-03-18 00:16:42 +01:00
Pouzor df3b7a8cb0 fix: show validation error when label is empty in NodeModal
Silent return when label was empty made the Add button appear broken.
Native browser required tooltip doesn't render in Radix Dialog portals.
Now shows inline red error message and highlights the field.
2026-03-16 01:01:47 +01:00
Remy 426af29180 Merge pull request #2 from Pouzor/feat/mcp-server
feat: MCP server for AI integration
2026-03-16 00:53:56 +01:00
153 changed files with 15986 additions and 2186 deletions
+6
View File
@@ -1,6 +1,7 @@
# Backend - server-side only (NEVER commit .env) # Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin # Auth — default credentials: admin / admin
@@ -22,3 +23,8 @@ STATUS_CHECKER_INTERVAL=60
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))" # Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
MCP_API_KEY=mcp_sk_changeme MCP_API_KEY=mcp_sk_changeme
MCP_SERVICE_KEY=svc_changeme MCP_SERVICE_KEY=svc_changeme
# Live view — read-only public canvas at /view?key=<value>
# Off by default. Set to a random secret to enable.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# LIVEVIEW_KEY=
+83
View File
@@ -0,0 +1,83 @@
name: Docker CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
smoke-and-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ── Write a minimal .env required by env_file: .env in docker-compose.yml ─
# AUTH_PASSWORD_HASH is NOT set here — it's injected via docker-compose.ci.yml
# environment section using $$ escaping to avoid docker-compose $VAR expansion.
- name: Write .env
run: |
{
echo "SECRET_KEY=ci-only-secret-key-not-for-production"
echo "SQLITE_PATH=/app/data/homelab.db"
echo 'CORS_ORIGINS=["http://localhost:3000"]'
echo 'SCANNER_RANGES=["127.0.0.1/32"]'
echo "STATUS_CHECKER_INTERVAL=300"
echo "MCP_API_KEY=ci-mcp-key"
echo "MCP_SERVICE_KEY=ci-svc-key"
} > .env
# ── Build + start backend and frontend (skip mcp) ─────────────────────────
# docker-compose.ci.yml: exposes port 8000 + injects AUTH_* env vars
- name: Build images
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build backend frontend
- name: Start stack
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
# ── Wait for backend to be healthy (max 60 s) ─────────────────────────────
- name: Wait for backend health
run: |
echo "Waiting for backend..."
for i in $(seq 1 30); do
if curl -sf http://localhost:8000/api/v1/health > /dev/null 2>&1; then
echo "Backend is up after ${i}s"
exit 0
fi
sleep 2
done
echo "Backend did not become healthy in time" >&2
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs backend
exit 1
# ── Smoke: frontend serves HTML ───────────────────────────────────────────
- name: Smoke — frontend returns 200
run: |
STATUS=$(curl -so /dev/null -w "%{http_code}" http://localhost:3000/)
[ "$STATUS" = "200" ] || { echo "Frontend returned $STATUS"; exit 1; }
# ── Tier 3: integration tests against the live stack ──────────────────────
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install backend test deps
run: pip install --quiet -r backend/requirements.txt
- name: Run integration tests
env:
INTEGRATION_BASE_URL: http://localhost:8000
INTEGRATION_USERNAME: admin
INTEGRATION_PASSWORD: admin
run: |
cd backend
pytest tests/test_integration.py -v
# ── Teardown ──────────────────────────────────────────────────────────────
- name: Dump logs on failure
if: failure()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml logs
- name: Stop stack
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down -v
+19
View File
@@ -7,6 +7,25 @@ on:
branches: [main] branches: [main]
jobs: jobs:
lint-scripts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: ShellCheck — lxc-install.sh
uses: ludeeus/action-shellcheck@2.0.0
with:
scandir: './scripts'
- name: Hadolint — Dockerfile.backend
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile.backend
ignore: DL3008
- name: Hadolint — Dockerfile.frontend
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile.frontend
ignore: DL3008
frontend: frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
+1
View File
@@ -45,6 +45,7 @@ htmlcov/
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
*.db.back
# Docker # Docker
.docker/ .docker/
+4
View File
@@ -0,0 +1,4 @@
# DL3008: pinning apt package versions is impractical for system tools (nmap, iputils-ping)
# that have version numbers tied to specific Debian releases.
ignore:
- DL3008
+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 # 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 && 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
+5 -1
View File
@@ -1,5 +1,8 @@
# Stage 1: build # Stage 1: build
FROM node:20-alpine AS builder # Use the native build platform so npm ci never runs under QEMU emulation.
# The build output (static HTML/JS/CSS) is platform-independent.
# 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
@@ -9,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
+105
View File
@@ -0,0 +1,105 @@
# Homelable — Installation
## Quick Start — Docker
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose up -d
```
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`.
>
Generate a new hash:
```bash
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
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
cd homelable && docker compose up -d
```
## Update (Docker)
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose pull && docker compose up -d
```
## Build from source
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
cp .env.example .env
docker compose up -d
```
---
## Proxmox LXC Install
You can now install Homelable with community-scripts (proxmox-VE) :
`https://community-scripts.org/scripts/homelable`
```bash
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
```
---
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
```env
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
# CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# How often to check node status (seconds)
STATUS_CHECKER_INTERVAL=60
```
All settings are also editable in-app via the **Scan Network** button.
---
## Development Mode
**Backend (Python 3.13):**
```bash
cd backend
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../.env.example .env # edit SECRET_KEY and review defaults
uvicorn app.main:app --reload --port 8000
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
+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.
+35 -119
View File
@@ -16,95 +16,15 @@ If you just like the design, you can only run the frontend and export your desig
<p align="center"> <p align="center">
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" /> <img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" /> <img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" /> <img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
</p> </p>
--- ---
## Quick Start — Docker ## Installation
```bash Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose up -d
```
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`.
>
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
>
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
## Quick Start - Front only
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
cd homelable && docker compose up -d
```
### Update
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
```bash
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
cd homelable && docker compose pull && docker compose up -d
```
### Build from source
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
cp .env.example .env
docker compose up -d
```
---
## Proxmox LXC Install
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
```bash
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.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)
> ```
---
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
```env
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
# CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
# How often to check node status (seconds)
STATUS_CHECKER_INTERVAL=60
```
All settings are also editable in-app via the **Scan Network** button.
--- ---
@@ -114,7 +34,8 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
### Triggering a scan ### Triggering a scan
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes. Errors are shown inline and as a toast notification. To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
### macOS / root privileges ### macOS / root privileges
@@ -137,19 +58,10 @@ Results are written directly to the database and appear as Pending Devices in th
--- ---
## Proxmox Nested Nodes
Proxmox nodes render as a resizable group container. VM and LXC nodes can be placed inside:
1. Add a **Proxmox VE** node to the canvas
2. Add a **VM** or **LXC** node — select the Proxmox node in the **Parent Proxmox** dropdown
3. The child node appears inside the group and moves with it
4. Select the Proxmox node to reveal resize handles (drag corners to expand)
---
## Node Check Methods ## Node Check Methods
Homelable continuously monitors your nodes and displays their live status (online / offline / unknown) directly on the canvas. Each node can be configured with an independent check method suited to the service it runs.
| Method | Description | | Method | Description |
|--------|-------------| |--------|-------------|
| `ping` | ICMP ping | | `ping` | ICMP ping |
@@ -162,9 +74,34 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
--- ---
## MCP Server (AI Integration) ## Live View (read-only public canvas)
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it. Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
### Activation
Add LIVEVIEW_KEY to your .env:
`LIVEVIEW_KEY=your-secret-key`
Then restart the backend:
`docker compose restart backend`
### Usage
Use this URL to view your canvas:
http://<your-homelab-ip>/view?key=your-secret-key
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
---
## MCP Server (AI Integration) (optionnal)
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
### What the AI can do ### What the AI can do
@@ -248,25 +185,4 @@ Or add it manually to `~/.claude.json`:
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`. - Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`.
- The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients. - The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
---
## Development Mode
**Backend (Python 3.13):**
```bash
cd backend
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp ../.env.example .env # edit SECRET_KEY and review defaults
uvicorn app.main:app --reload --port 8000
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev # http://localhost:5173
```
--- ---
+1
View File
@@ -0,0 +1 @@
1.10.2
+41
View File
@@ -0,0 +1,41 @@
import hmac
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node
from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def liveview_canvas(
key: str | None = Query(default=None),
db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse:
"""Read-only public canvas endpoint.
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
Always returns 403 when disabled, regardless of the key provided.
"""
if not settings.liveview_key:
raise HTTPException(status_code=403, detail="Live view is disabled")
if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
)
+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
+16 -4
View File
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
@router.websocket("/ws/status") @router.websocket("/ws/status")
async def ws_status(websocket: WebSocket, token: str | None = None) -> None: async def ws_status(websocket: WebSocket) -> None:
if not token or not decode_token(token): # Accept first so we can send a close frame with a reason code
await websocket.close(code=1008) # Policy Violation
return
await websocket.accept() await websocket.accept()
try:
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
_connections.append(websocket) _connections.append(websocket)
try: try:
while True: while True:
+31
View File
@@ -1,8 +1,23 @@
import json import json
import logging
from pathlib import Path from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
Path("/app/VERSION"), # Docker image
]:
if candidate.exists():
return candidate.read_text().strip()
return "unknown"
APP_VERSION = _read_version()
class Settings(BaseSettings): 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")
@@ -19,6 +34,17 @@ class Settings(BaseSettings):
auth_username: str = "admin" auth_username: str = "admin"
auth_password_hash: str = "" auth_password_hash: str = ""
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
return self
# Scanner # Scanner
scanner_ranges: list[str] = ["192.168.1.0/24"] scanner_ranges: list[str] = ["192.168.1.0/24"]
@@ -30,6 +56,11 @@ class Settings(BaseSettings):
# Leave empty to disable MCP service key auth. # Leave empty to disable MCP service key auth.
mcp_service_key: str = "" mcp_service_key: str = ""
# Live view — optional read-only public canvas endpoint.
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
# Leave unset (or empty) to keep the feature disabled (default).
liveview_key: str | None = None
def _override_path(self) -> Path: def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json" return Path(self.sqlite_path).parent / "scan_config.json"
+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)
+4 -1
View File
@@ -9,7 +9,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool: def verify_password(plain: str, hashed: str) -> bool:
return bool(pwd_context.verify(plain, hashed)) try:
return bool(pwd_context.verify(plain, hashed))
except ValueError:
return False
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
+80 -9
View File
@@ -1,11 +1,16 @@
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
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)
@@ -22,26 +27,92 @@ 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)
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(Exception): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
with suppress(OperationalError):
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')
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
async def get_db() -> AsyncGenerator[AsyncSession, None]: async def get_db() -> AsyncGenerator[AsyncSession, None]:
+13 -3
View File
@@ -33,15 +33,23 @@ class Node(Base):
notes: Mapped[str | None] = mapped_column(Text) notes: Mapped[str | None] = mapped_column(Text)
pos_x: Mapped[float] = mapped_column(Float, default=0) pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0) pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id")) parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False) container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True) custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
ram_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)
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
width: 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])
@@ -58,9 +66,10 @@ class Edge(Base):
speed: Mapped[str | None] = mapped_column(String) speed: Mapped[str | None] = mapped_column(String)
custom_color: Mapped[str | None] = mapped_column(String) custom_color: Mapped[str | None] = mapped_column(String)
path_style: Mapped[str | None] = mapped_column(String) path_style: Mapped[str | None] = mapped_column(String)
animated: Mapped[bool] = mapped_column(Boolean, default=False) 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)
@@ -83,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)
+19 -4
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
@@ -5,7 +7,8 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, 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.0.0", version="1.9.0",
lifespan=lifespan, lifespan=lifespan,
) )
@@ -30,8 +43,8 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["*"], allow_headers=["Authorization", "Content-Type"],
) )
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
@@ -40,6 +53,8 @@ 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.get("/api/v1/health") @app.get("/api/v1/health")
+18 -2
View File
@@ -1,9 +1,10 @@
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated
class NodeSave(BaseModel): class NodeSave(BaseModel):
@@ -23,6 +24,15 @@ class NodeSave(BaseModel):
container_mode: bool = False container_mode: bool = False
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
properties: list[Any] = []
width: 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
@@ -37,9 +47,15 @@ class EdgeSave(BaseModel):
speed: str | None = None speed: str | None = None
custom_color: str | None = None custom_color: str | None = None
path_style: str | None = None path_style: str | None = None
animated: bool = False 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')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
class CanvasSaveRequest(BaseModel): class CanvasSaveRequest(BaseModel):
+19 -3
View File
@@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, field_validator
from app.schemas.utils import normalize_animated
class EdgeBase(BaseModel): class EdgeBase(BaseModel):
@@ -12,9 +14,15 @@ class EdgeBase(BaseModel):
speed: str | None = None speed: str | None = None
custom_color: str | None = None custom_color: str | None = None
path_style: str | None = None path_style: str | None = None
animated: bool = False 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')
@classmethod
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
class EdgeCreate(EdgeBase): class EdgeCreate(EdgeBase):
@@ -28,9 +36,17 @@ class EdgeUpdate(BaseModel):
speed: str | None = None speed: str | None = None
custom_color: str | None = None custom_color: str | None = None
path_style: str | None = None path_style: str | None = None
animated: bool | 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')
@classmethod
def validate_animated(cls, v: object) -> str | None:
if v is None:
return None
return normalize_animated(v)
class EdgeResponse(EdgeBase): class EdgeResponse(EdgeBase):
+18
View File
@@ -22,6 +22,15 @@ class NodeBase(BaseModel):
container_mode: bool = False container_mode: bool = False
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
properties: list[dict[str, Any]] = []
width: float | None = None
height: float | None = None
bottom_handles: int = 1
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
@@ -46,6 +55,15 @@ class NodeUpdate(BaseModel):
container_mode: bool | None = None container_mode: bool | None = None
custom_colors: dict[str, Any] | None = None custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None custom_icon: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool | None = None
properties: list[dict[str, Any]] | None = None
width: 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}
+9
View File
@@ -0,0 +1,9 @@
def normalize_animated(v: object) -> str:
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
if v is True or v == 1 or v == '1':
return 'snake'
if v is False or v == 0 or v == '0' or v is None or v == 'none':
return 'none'
if v in ('snake', 'flow', 'basic'):
return str(v)
return 'none'
+62 -12
View File
@@ -1,18 +1,28 @@
"""Match nmap scan results against service_signatures.json.""" """Match nmap scan results against service_signatures.json."""
import json import json
import re import re
import threading
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
_SIGNATURES: list[dict[str, Any]] | None = None _SIGNATURES: list[dict[str, Any]] | None = None
_LOCK = threading.Lock()
def _load() -> list[dict[str, Any]]: def _load() -> list[dict[str, Any]]:
global _SIGNATURES global _SIGNATURES
if _SIGNATURES is None: if _SIGNATURES is None:
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json" with _LOCK:
with open(path) as f: if _SIGNATURES is None:
_SIGNATURES = json.load(f) path = Path(__file__).parent.parent / "data" / "service_signatures.json"
try:
with open(path) as f:
_SIGNATURES = json.load(f)
except FileNotFoundError as err:
raise FileNotFoundError(
f"service_signatures.json not found at {path}. "
"This file should be bundled with the application."
) from err
return _SIGNATURES return _SIGNATURES
@@ -55,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",
} }
@@ -91,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
@@ -105,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"]
@@ -116,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
+12
View File
@@ -56,3 +56,15 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
settings.mcp_service_key = "" settings.mcp_service_key = ""
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"}) res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
assert res.status_code == 401 assert res.status_code == 401
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
from app.core.config import settings
original = settings.auth_password_hash
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
try:
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 401
finally:
settings.auth_password_hash = original
+419
View File
@@ -104,6 +104,25 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"} assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
"""label_position and text_size are stored in custom_colors and returned unchanged."""
n1 = node_payload(custom_colors={
"border": "#00d4ff",
"border_style": "solid",
"border_width": 3,
"label_position": "outside",
"text_size": 16,
"text_color": "#e6edf3",
})
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
cc = canvas["nodes"][0]["custom_colors"]
assert cc["label_position"] == "outside"
assert cc["text_size"] == 16
assert cc["border_width"] == 3
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict): async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
n1 = node_payload() n1 = node_payload()
n2 = node_payload() n2 = node_payload()
@@ -138,3 +157,403 @@ async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, he
async def test_save_canvas_requires_auth(client: AsyncClient): async def test_save_canvas_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}) res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
assert res.status_code == 401 assert res.status_code == 401
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
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["cpu_count"] == 8
assert node["cpu_model"] == "Intel i7-12700K"
assert node["ram_gb"] == 32.0
assert node["disk_gb"] == 500.0
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=4, ram_gb=16.0)
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["cpu_count"] == 4
assert node["ram_gb"] == 16.0
assert node["cpu_model"] is None
assert node["disk_gb"] is None
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
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]["show_hardware"] is True
async def test_save_canvas_show_hardware_defaults_false(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]["show_hardware"] is False
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
n1 = node_payload(cpu_count=8, ram_gb=32.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
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()
node = canvas["nodes"][0]
assert node["cpu_count"] is None
assert node["ram_gb"] is None
# ── node width / height (resizable nodes) ─────────────────────────────────────
async def test_save_canvas_persists_node_dimensions(client: AsyncClient, headers: dict):
n1 = node_payload(width=320.0, height=180.0)
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["width"] == 320.0
assert node["height"] == 180.0
async def test_save_canvas_dimensions_default_null(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]["width"] is None
assert canvas["nodes"][0]["height"] is None
async def test_save_canvas_dimensions_updated_on_resize(client: AsyncClient, headers: dict):
n1 = node_payload(width=140.0, height=50.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_resized = {**n1, "width": 280.0, "height": 120.0}
await client.post("/api/v1/canvas/save", json={"nodes": [n1_resized], "edges": [], "viewport": {}}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
node = canvas["nodes"][0]
assert node["width"] == 280.0
assert node["height"] == 120.0
async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, headers: dict):
n1 = node_payload(width=300.0, height=200.0)
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
n1_cleared = {**n1, "width": None, "height": None}
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]["width"] 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"
+185
View File
@@ -0,0 +1,185 @@
"""
Integration tests — run against a live Docker stack.
Skipped unless INTEGRATION_BASE_URL is set (done automatically in docker-ci.yml).
Usage (local):
INTEGRATION_BASE_URL=http://localhost:8000 \
INTEGRATION_USERNAME=admin \
INTEGRATION_PASSWORD=your-password \
pytest backend/tests/test_integration.py -v
"""
import os
import httpx
import pytest
BASE_URL = os.environ.get("INTEGRATION_BASE_URL", "")
USERNAME = os.environ.get("INTEGRATION_USERNAME", "admin")
_PASSWORD_RAW = os.environ.get("INTEGRATION_PASSWORD", "")
pytestmark = pytest.mark.skipif(
not BASE_URL,
reason="INTEGRATION_BASE_URL not set — skipping live-stack tests",
)
def _require_password() -> str:
if not _PASSWORD_RAW:
pytest.fail("INTEGRATION_PASSWORD env var is required for live-stack tests")
return _PASSWORD_RAW
PASSWORD = _PASSWORD_RAW # resolved at call time via _require_password() in fixture
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture(scope="module")
def token() -> str:
pw = _require_password()
res = httpx.post(
f"{BASE_URL}/api/v1/auth/login",
json={"username": USERNAME, "password": pw},
timeout=10,
)
assert res.status_code == 200, f"Login failed ({res.status_code}): {res.text}"
return res.json()["access_token"]
@pytest.fixture(scope="module")
def auth(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
@pytest.fixture()
def restored_canvas(auth):
"""Save the current canvas before the test and restore it afterward."""
before = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
yield
httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=before, headers=auth, timeout=10)
def _save_canvas(auth, nodes, edges=None):
payload = {
"nodes": nodes,
"edges": edges or [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
res = httpx.post(f"{BASE_URL}/api/v1/canvas/save", json=payload, headers=auth, timeout=10)
assert res.status_code == 200, f"Canvas save failed ({res.status_code}): {res.text}"
return res
def _node(node_id: str, label: str, node_type: str = "server", **extra) -> dict:
"""Build a NodeSave-compatible dict (flat API format, not React Flow format)."""
return {
"id": node_id,
"type": node_type,
"label": label,
"status": "unknown",
"services": [],
"pos_x": extra.pop("pos_x", 0),
"pos_y": extra.pop("pos_y", 0),
**extra,
}
# ── Health ────────────────────────────────────────────────────────────────────
def test_health_endpoint():
res = httpx.get(f"{BASE_URL}/api/v1/health", timeout=10)
assert res.status_code == 200
# ── Auth ──────────────────────────────────────────────────────────────────────
def test_login_returns_token():
pw = _require_password()
res = httpx.post(
f"{BASE_URL}/api/v1/auth/login",
json={"username": USERNAME, "password": pw},
timeout=10,
)
assert res.status_code == 200
data = res.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_bad_credentials():
res = httpx.post(
f"{BASE_URL}/api/v1/auth/login",
json={"username": USERNAME, "password": "definitely-wrong"},
timeout=10,
)
assert res.status_code == 401
def test_protected_route_without_token():
res = httpx.get(f"{BASE_URL}/api/v1/canvas", timeout=10)
assert res.status_code == 401
# ── Canvas round-trip ─────────────────────────────────────────────────────────
def test_canvas_load_returns_valid_structure(auth):
res = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10)
assert res.status_code == 200
data = res.json()
assert "nodes" in data
assert "edges" in data
assert isinstance(data["nodes"], list)
assert isinstance(data["edges"], list)
def test_canvas_save_and_reload(auth, restored_canvas):
_save_canvas(auth, [_node("integ-node-1", "CI Server", pos_x=100, pos_y=200)])
data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
assert len(data["nodes"]) == 1
node = data["nodes"][0]
assert node["id"] == "integ-node-1"
assert node["label"] == "CI Server"
assert node["type"] == "server"
assert node["pos_x"] == 100
assert node["pos_y"] == 200
def test_canvas_save_preserves_node_dimensions(auth, restored_canvas):
"""Width/height survive a save→reload cycle through the real DB."""
_save_canvas(auth, [
_node("resized-node", "Big Router", node_type="router", width=320.0, height=150.0)
])
nodes = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()["nodes"]
node = next((n for n in nodes if n["id"] == "resized-node"), None)
assert node is not None
assert node["width"] == 320.0
assert node["height"] == 150.0
def test_canvas_save_with_edge(auth, restored_canvas):
_save_canvas(
auth,
nodes=[
_node("n-src", "Router", node_type="router"),
_node("n-dst", "Server", node_type="server", pos_x=200),
],
edges=[{
"id": "e-eth",
"source": "n-src",
"target": "n-dst",
"type": "ethernet",
}],
)
data = httpx.get(f"{BASE_URL}/api/v1/canvas", headers=auth, timeout=10).json()
assert len(data["edges"]) == 1
edge = data["edges"][0]
# EdgeResponse uses source/target (not source_id/target_id)
assert edge["source"] == "n-src"
assert edge["target"] == "n-dst"
assert edge["type"] == "ethernet"
+126
View File
@@ -0,0 +1,126 @@
"""
Tests for the /api/v1/liveview read-only canvas endpoint.
The endpoint is:
- Disabled by default (LIVEVIEW_KEY not set) → 403
- Returns 403 for missing or wrong key even when enabled
- Returns canvas data for a valid key (no JWT required)
"""
import pytest
from httpx import AsyncClient
from app.core.config import settings
@pytest.fixture(autouse=True)
def reset_liveview_key():
"""Restore liveview_key after each test so tests are isolated."""
original = settings.liveview_key
yield
settings.liveview_key = original
# ── Disabled (no key configured) ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_by_default(client: AsyncClient):
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
@pytest.mark.asyncio
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
settings.liveview_key = ""
res = await client.get("/api/v1/liveview?key=anything")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
# ── Enabled but wrong / missing key ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_wrong_key(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview?key=wrong-key")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
@pytest.mark.asyncio
async def test_liveview_missing_key_param(client: AsyncClient):
settings.liveview_key = "correct-secret"
res = await client.get("/api/v1/liveview")
assert res.status_code == 403
assert res.json()["detail"] == "Invalid live view key"
# ── Valid key — no JWT needed ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
settings.liveview_key = "my-secret-key"
res = await client.get("/api/v1/liveview?key=my-secret-key")
assert res.status_code == 200
data = res.json()
assert "nodes" in data
assert "edges" in data
assert "viewport" in data
assert isinstance(data["nodes"], list)
assert isinstance(data["edges"], list)
@pytest.mark.asyncio
async def test_liveview_does_not_require_jwt(client: AsyncClient):
"""Accessing without Authorization header must work when key is correct."""
settings.liveview_key = "open-sesame"
# client has no auth headers set here
res = await client.get("/api/v1/liveview?key=open-sesame")
assert res.status_code == 200
@pytest.mark.asyncio
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
"""Canvas saved via POST /canvas/save appears in liveview response."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Save a canvas with one node
payload = {
"nodes": [{
"id": "lv-node-1",
"type": "server",
"label": "Live Node",
"status": "online",
"services": [],
"pos_x": 10,
"pos_y": 20,
}],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
# Liveview should return the same node
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
nodes = res.json()["nodes"]
assert len(nodes) == 1
assert nodes[0]["id"] == "lv-node-1"
assert nodes[0]["label"] == "Live Node"
# ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
settings.liveview_key = "was-enabled"
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 200
settings.liveview_key = None
res = await client.get("/api/v1/liveview?key=was-enabled")
assert res.status_code == 403
assert res.json()["detail"] == "Live view is disabled"
+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
+19 -10
View File
@@ -22,24 +22,33 @@ def _make_token() -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_websocket_rejected_without_token(): def test_websocket_rejected_without_token():
"""Connection with no token must be closed before being accepted.""" """Connection that sends no token field must be closed with 1008."""
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"): with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
pass ws.send_text(json.dumps({})) # missing token field
ws.receive_text() # triggers WebSocketDisconnect from server close
def test_websocket_rejected_with_invalid_token(): def test_websocket_rejected_with_invalid_token():
"""Connection with a garbage token must be closed.""" """Connection that sends a garbage token must be closed."""
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"): with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
pass ws.send_text(json.dumps({"token": "not-a-valid-jwt"}))
ws.receive_text()
def test_websocket_rejected_with_malformed_json():
"""Connection that sends non-JSON as auth must be closed."""
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
ws.send_text("not-json")
ws.receive_text()
def test_websocket_accepted_with_valid_token(): def test_websocket_accepted_with_valid_token():
"""Connection with a valid JWT must be accepted and kept open.""" """Connection that sends a valid JWT as first message must be accepted."""
token = _make_token() token = _make_token()
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws: with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
# Connection is open — we can send a ping and it should not raise ws.send_text(json.dumps({"token": token}))
# Connection is open — subsequent messages should not raise
ws.send_text("ping") ws.send_text("ping")
# Server keeps the connection open (no disconnect expected)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+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
+14
View File
@@ -0,0 +1,14 @@
# CI override — exposes backend port 8000 and injects credentials.
# Usage: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d backend frontend
#
# AUTH_PASSWORD_HASH uses $$ escaping: docker-compose converts $$ → $ before
# passing to the container, so the backend receives a valid bcrypt hash.
# This avoids the project .env file being subject to docker-compose $VAR expansion.
services:
backend:
ports:
- "8000:8000"
environment:
AUTH_USERNAME: admin
# bcrypt hash of "admin" — $$ is docker-compose escape for literal $
AUTH_PASSWORD_HASH: $$2b$$12$$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
+7 -2
View File
@@ -7,9 +7,8 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
# Override env_file values that differ in Docker # Override env_file: SQLite path must point inside the container volume
SQLITE_PATH: /app/data/homelab.db SQLITE_PATH: /app/data/homelab.db
CORS_ORIGINS: '["http://localhost:3000"]'
volumes: volumes:
- backend_data:/app/data - backend_data:/app/data
networks: networks:
@@ -17,6 +16,12 @@ services:
# Required for ping-based status checks # Required for ping-based status checks
cap_add: cap_add:
- NET_RAW - NET_RAW
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
interval: 10s
timeout: 5s
retries: 6
start_period: 15s
mcp: mcp:
build: build:
+11 -1
View File
@@ -4,6 +4,16 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Proxy WebSocket (must be before /api/ to take priority)
location /api/v1/status/ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Proxy API to backend # Proxy API to backend
location /api/ { location /api/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
@@ -11,7 +21,7 @@ server {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
# Proxy WebSocket # Proxy legacy /ws/ path
location /ws/ { location /ws/ {
proxy_pass http://backend:8000; proxy_pass http://backend:8000;
proxy_http_version 1.1; proxy_http_version 1.1;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 KiB

+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

+1133 -764
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.10.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -19,12 +19,15 @@
"@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",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"js-yaml": "^4.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -46,12 +49,12 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1", "eslint": "^9.39.0",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"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",
+138 -136
View File
@@ -2,8 +2,12 @@ import { useEffect, useCallback, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react' import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react' import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout' import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export' import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -29,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, 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()
@@ -40,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)
@@ -47,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 () => {
@@ -57,71 +65,8 @@ export default function App() {
toast.success('Canvas saved') toast.success('Canvas saved')
return return
} }
const nodesToSave = nodes.map((n) => { const nodesToSave = nodes.map(serializeNode)
if (n.data.type === 'groupRect') { const edgesToSave = edges.map(serializeEdge)
return {
id: n.id,
type: 'groupRect',
label: n.data.label,
hostname: null,
ip: null,
mac: null,
os: null,
status: 'unknown',
check_method: null,
check_target: null,
services: [],
notes: null,
parent_id: null,
container_mode: false,
custom_icon: null,
pos_x: n.position.x,
pos_y: n.position.y,
// Persist size and all rect config inside custom_colors
custom_colors: {
...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360,
height: n.measured?.height ?? n.height ?? 240,
},
}
}
return {
id: n.id,
type: n.data.type,
label: n.data.label,
hostname: n.data.hostname ?? null,
ip: n.data.ip ?? null,
mac: n.data.mac ?? null,
os: n.data.os ?? null,
status: n.data.status,
check_method: n.data.check_method ?? null,
check_target: n.data.check_target ?? null,
services: n.data.services ?? [],
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
custom_colors: n.data.custom_colors ?? null,
custom_icon: n.data.custom_icon ?? null,
pos_x: n.position.x,
pos_y: n.position.y,
}
})
const edgesToSave = edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.data?.type ?? 'ethernet',
label: e.data?.label ?? null,
vlan_id: e.data?.vlan_id ?? null,
speed: e.data?.speed ?? null,
custom_color: e.data?.custom_color ?? null,
path_style: e.data?.path_style ?? null,
animated: e.data?.animated ?? false,
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
// map them back to their canonical source handle ID so reload works correctly.
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null),
}))
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } }) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
@@ -156,46 +101,14 @@ 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 (apiNodes as ApiNode[])
.filter((n: NodeData & { id: string }) => n.type === 'proxmox') .filter((n) => n.type === 'group' || n.container_mode === true)
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false]) .map((n) => [n.id, true])
) )
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => { const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
if (n.type === 'groupRect') { const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const w = n.custom_colors?.width ?? 360
const h = n.custom_colors?.height ?? 240
const z = n.custom_colors?.z_order ?? 1
return {
id: n.id,
type: 'groupRect',
position: { x: n.pos_x, y: n.pos_y },
data: n,
width: w,
height: h,
zIndex: z - 10,
}
}
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return {
id: n.id,
type: n.type,
position: { x: n.pos_x, y: n.pos_y },
data: n,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.type === 'proxmox' && n.container_mode !== false ? { width: 300, height: 200 } : {}),
}
})
const rfEdges = apiEdges.map((e: EdgeData & { id: string; source: string; target: string; source_handle?: string; target_handle?: string }) => ({
id: e.id,
source: e.source,
target: e.target,
type: e.type,
sourceHandle: e.source_handle ?? null,
targetHandle: e.target_handle ?? null,
data: e,
}))
const savedTheme = res.data.viewport?.theme_id const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme) if (savedTheme) setTheme(savedTheme)
loadCanvas(rfNodes, rfEdges) loadCanvas(rfNodes, rfEdges)
@@ -238,8 +151,8 @@ export default function App() {
const handleAddNode = useCallback((data: Partial<NodeData>) => { const handleAddNode = useCallback((data: Partial<NodeData>) => {
snapshotHistory() snapshotHistory()
const id = crypto.randomUUID() 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
@@ -252,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}"`)
@@ -260,7 +173,7 @@ export default function App() {
const handleAddGroupRect = useCallback((data: GroupRectFormData) => { const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
snapshotHistory() snapshotHistory()
const id = crypto.randomUUID() const id = generateUUID()
const newNode: Node<NodeData> = { const newNode: Node<NodeData> = {
id, id,
type: 'groupRect', type: 'groupRect',
@@ -272,9 +185,13 @@ export default function App() {
services: [], services: [],
custom_colors: { custom_colors: {
border: data.border_color, border: data.border_color,
border_style: data.border_style,
border_width: data.border_width,
background: data.background_color, background: data.background_color,
text_color: data.text_color, text_color: data.text_color,
text_position: data.text_position, text_position: data.text_position,
text_size: data.text_size,
label_position: data.label_position,
font: data.font, font: data.font,
z_order: data.z_order, z_order: data.z_order,
}, },
@@ -288,22 +205,27 @@ export default function App() {
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => { const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
if (!editingGroupRectId) return if (!editingGroupRectId) return
snapshotHistory()
const existing = nodes.find((n) => n.id === editingGroupRectId) const existing = nodes.find((n) => n.id === editingGroupRectId)
updateNode(editingGroupRectId, { updateNode(editingGroupRectId, {
label: data.label, label: data.label,
custom_colors: { custom_colors: {
...existing?.data.custom_colors, ...existing?.data.custom_colors,
border: data.border_color, border: data.border_color,
border_style: data.border_style,
border_width: data.border_width,
background: data.background_color, background: data.background_color,
text_color: data.text_color, text_color: data.text_color,
text_position: data.text_position, text_position: data.text_position,
text_size: data.text_size,
label_position: data.label_position,
font: data.font, font: data.font,
z_order: data.z_order, z_order: data.z_order,
}, },
}) })
setNodeZIndex(editingGroupRectId, data.z_order - 10) setNodeZIndex(editingGroupRectId, data.z_order - 10)
setEditingGroupRectId(null) setEditingGroupRectId(null)
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId]) }, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
const handleDeleteGroupRect = useCallback(() => { const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return if (!editingGroupRectId) return
@@ -321,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) {
@@ -340,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)
}
} }
} }
} }
@@ -363,15 +288,29 @@ export default function App() {
toast.success('Markdown table copied to clipboard') toast.success('Markdown table copied to clipboard')
}, [nodes]) }, [nodes])
const handleExport = useCallback(async () => { const handleExportYaml = useCallback(() => {
if (nodes.length === 0) { toast.error('No nodes to export'); return }
const content = exportCanvasToYaml(nodes, edges)
downloadYaml(content)
toast.success('Canvas exported as YAML')
}, [nodes, edges])
const handleImportYaml = useCallback((content: string) => {
try {
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
snapshotHistory()
loadCanvas(merged, mergedEdges)
markUnsaved()
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
} catch (err) {
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
}
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
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) => {
@@ -382,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 })
} }
} }
@@ -401,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()
@@ -415,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
@@ -430,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
@@ -441,22 +397,40 @@ export default function App() {
onRedo={redo} onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)} onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd} onExportMd={handleExportMd}
onExportYaml={handleExportYaml}
onImportYaml={handleImportYaml}
/> />
<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 */}
@@ -467,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
@@ -488,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"
/> />
@@ -496,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)
}}
/> />
)} )}
@@ -504,7 +485,7 @@ export default function App() {
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
onSubmit={handleAddGroupRect} onSubmit={handleAddGroupRect}
title="Add Rectangle" title="Add Zone"
/> />
{/* key forces re-mount when editing a different rect */} {/* key forces re-mount when editing a different rect */}
@@ -524,11 +505,15 @@ export default function App() {
text_color: rc.text_color ?? '#e6edf3', text_color: rc.text_color ?? '#e6edf3',
text_position: rc.text_position ?? 'top-left', text_position: rc.text_position ?? 'top-left',
border_color: rc.border ?? '#00d4ff', border_color: rc.border ?? '#00d4ff',
border_style: rc.border_style ?? 'solid',
border_width: rc.border_width ?? 2,
background_color: rc.background ?? '#00d4ff0d', background_color: rc.background ?? '#00d4ff0d',
text_size: rc.text_size ?? 12,
label_position: rc.label_position ?? 'inside',
z_order: rc.z_order ?? 1, z_order: rc.z_order ?? 1,
} }
})()} })()}
title="Edit Rectangle" title="Edit Zone"
/> />
{/* key forces re-mount on open so useState captures current theme as original */} {/* key forces re-mount on open so useState captures current theme as original */}
@@ -538,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>
+18 -2
View File
@@ -5,6 +5,9 @@ export const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
}) })
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token const token = useAuthStore.getState().token
if (token) config.headers.Authorization = `Bearer ${token}` if (token) config.headers.Authorization = `Bearer ${token}`
@@ -44,14 +47,27 @@ export const edgesApi = {
delete: (id: string) => api.delete(`/edges/${id}`), delete: (id: string) => api.delete(`/edges/${id}`),
} }
export const liveviewApi = {
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
}
export const scanApi = { export const scanApi = {
trigger: () => api.post('/scan/trigger'), trigger: () => api.post('/scan/trigger'),
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),
} }
+165
View File
@@ -0,0 +1,165 @@
/**
* LiveView — read-only canvas accessible at /view?key=<LIVEVIEW_KEY>.
*
* - Non-standalone: fetches canvas from /api/v1/liveview?key=... (no JWT needed).
* Returns 403 when the feature is disabled or the key is wrong.
* - Standalone: loads canvas from localStorage directly (no key required,
* since there is no backend to validate against).
*
* Pan and zoom work. Editing is fully disabled.
* Clicking a node with an IP opens http://<ip> in a new tab.
*/
import { useCallback, useEffect, useState } from 'react'
import {
ReactFlowProvider,
ReactFlow,
Background,
BackgroundVariant,
Controls,
ConnectionMode,
useReactFlow,
type Node,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { liveviewApi } from '@/api/client'
import type { NodeData } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
function LiveViewCanvas() {
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required)
// - non-standalone, no ?key= → no-key error immediately
// - non-standalone, key present → loading (API call below)
const [viewState, setViewState] = useState<ViewState>(() => {
if (STANDALONE) return 'ready'
return new URLSearchParams(window.location.search).get('key') ? 'loading' : 'no-key'
})
useEffect(() => {
if (STANDALONE) {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
loadCanvas(savedNodes, savedEdges)
}
} catch {
// empty canvas on parse error — show empty canvas
}
return
}
// Already handled synchronously in useState initializer
const key = new URLSearchParams(window.location.search).get('key')
if (!key) return
liveviewApi.load(key)
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
)
loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
setViewState('ready')
})
.catch((err) => {
if (!err.response) { setViewState('network-error'); return }
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
}, [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 ip = node.data.ip
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
}, [])
if (viewState === 'loading') {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
Loading
</div>
)
}
if (viewState !== 'ready') {
const messages: Record<Exclude<ViewState, 'loading' | 'ready'>, string> = {
disabled: 'Live view is disabled on this instance.',
'invalid-key': 'Invalid or expired live view key.',
'no-key': 'Missing key — use ?key=your-secret in the URL.',
'network-error': 'Could not reach the server. Check your connection.',
}
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117]">
<div className="text-center space-y-2">
<p className="text-[#f85149] text-lg font-medium">Access Denied</p>
<p className="text-[#8b949e] text-sm">{messages[viewState]}</p>
</div>
</div>
)
}
return (
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
panOnDrag
zoomOnScroll
colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick}
>
<Background
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color={theme.colors.canvasDotColor}
/>
<Controls showInteractive={false} />
</ReactFlow>
</div>
)
}
export default function LiveView() {
return (
<ReactFlowProvider>
<LiveViewCanvas />
</ReactFlowProvider>
)
}
+4 -3
View File
@@ -20,8 +20,9 @@ export function LoginPage() {
try { try {
const res = await authApi.login(username, password) const res = await authApi.login(username, password)
login(res.data.access_token) login(res.data.access_token)
} catch { } catch (err: unknown) {
setError('Invalid username or password') const hasResponse = err && typeof err === 'object' && 'response' in err
setError(hasResponse ? 'Invalid username or password' : 'Could not reach the server — check your CORS_ORIGINS setting')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -95,7 +96,7 @@ export function LoginPage() {
</form> </form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4"> <p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">config.yml</span> Credentials configured in <span className="font-mono">.env</span>
</p> </p>
</div> </div>
</div> </div>
@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
// ── Mock heavy dependencies ────────────────────────────────────────────────
vi.mock('@xyflow/react', () => ({
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() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
vi.mock('@/api/client', () => ({
liveviewApi: { load: vi.fn() },
}))
import { liveviewApi } from '@/api/client'
import LiveView from '../LiveView'
// ── Helpers ────────────────────────────────────────────────────────────────
function setSearch(params: string) {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search: params, pathname: '/view' },
})
}
const canvasPayload = {
data: {
nodes: [{
id: 'n1', type: 'server', label: 'CI Node', status: 'online',
services: [], pos_x: 0, pos_y: 0,
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
}],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
}
// ── Tests ──────────────────────────────────────────────────────────────────
describe('LiveView (non-standalone)', () => {
beforeEach(() => {
vi.mocked(liveviewApi.load).mockReset()
useCanvasStore.setState({ nodes: [], edges: [] })
})
afterEach(() => { setSearch('') })
// ── No key ────────────────────────────────────────────────────────────────
it('shows no-key error when ?key= is missing', async () => {
setSearch('')
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText('Access Denied')).toBeDefined()
expect(screen.getByText(/Missing key/)).toBeDefined()
})
expect(liveviewApi.load).not.toHaveBeenCalled()
})
// ── Disabled ──────────────────────────────────────────────────────────────
it('shows disabled error when backend returns "Live view is disabled"', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue({
response: { data: { detail: 'Live view is disabled' } },
})
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/disabled on this instance/)).toBeDefined()
})
})
// ── Invalid key ───────────────────────────────────────────────────────────
it('shows invalid-key error when backend returns "Invalid live view key"', async () => {
setSearch('?key=wrong')
vi.mocked(liveviewApi.load).mockRejectedValue({
response: { data: { detail: 'Invalid live view key' } },
})
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Invalid or expired/)).toBeDefined()
})
})
it('shows network-error for non-response errors (offline, CORS, 500)', async () => {
setSearch('?key=anything')
vi.mocked(liveviewApi.load).mockRejectedValue(new Error('network'))
render(<LiveView />)
await waitFor(() => {
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})
// ── Valid key → canvas rendered ───────────────────────────────────────────
it('renders the canvas on valid key', async () => {
setSearch('?key=correct-key')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
})
it('loads nodes into the canvas store on success', async () => {
setSearch('?key=secret')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => {
expect(screen.getByTestId('react-flow')).toBeDefined()
})
const { nodes } = useCanvasStore.getState()
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
})
// ── No editing props passed ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => {
setSearch('?key=valid')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
expect(screen.queryByText('Access Denied')).toBeNull()
})
})
// ── 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)', () => {
beforeEach(() => {
localStorage.clear()
useCanvasStore.setState({ nodes: [], edges: [] })
})
afterEach(() => {
setSearch('')
vi.unstubAllEnvs()
})
it('loads canvas from localStorage without calling the API', async () => {
const stored = {
nodes: [{
id: 'ls-node', type: 'router',
position: { x: 10, y: 20 },
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
}],
edges: [],
}
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
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()
})
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()
})
})
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginPage } from '../LoginPage'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/api/client', () => ({
authApi: {
login: vi.fn(),
},
}))
import { authApi } from '@/api/client'
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
vi.mocked(authApi.login).mockReset()
})
// ── Rendering ────────────────────────────────────────────────────────────
it('renders username and password fields', () => {
render(<LoginPage />)
expect(screen.getByLabelText('Username')).toBeDefined()
expect(screen.getByLabelText('Password')).toBeDefined()
})
it('renders a Sign in button', () => {
render(<LoginPage />)
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.type).toBe('password')
})
it('username field has autocomplete="username"', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.getAttribute('autocomplete')).toBe('username')
})
it('password field has autocomplete="current-password" (supports password managers)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.getAttribute('autocomplete')).toBe('current-password')
})
it('shows a generic error message — no credential enumeration', async () => {
vi.mocked(authApi.login).mockRejectedValue({ response: { status: 401 } })
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText('Invalid username or password')).toBeDefined()
})
// Must show exactly ONE error — not separate per-field messages (no enumeration)
const errors = document.querySelectorAll('p.text-\\[\\#f85149\\]')
expect(errors.length).toBe(1)
expect(errors[0].textContent).toBe('Invalid username or password')
})
it('shows a network error message when no response (e.g. CORS misconfiguration)', async () => {
vi.mocked(authApi.login).mockRejectedValue(new Error('Network Error'))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'admin' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
})
})
it('clears previous error before each new attempt', async () => {
vi.mocked(authApi.login)
.mockRejectedValueOnce({ response: { status: 401 } })
.mockRejectedValueOnce({ response: { status: 401 } })
render(<LoginPage />)
const form = screen.getByRole('button', { name: /sign in/i }).closest('form')!
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'bad' } })
fireEvent.submit(form)
await waitFor(() => screen.getByText('Invalid username or password'))
fireEvent.submit(form)
// Error clears while loading (setError('') before try)
await waitFor(() => screen.getByText('Invalid username or password'))
expect(screen.getAllByText('Invalid username or password')).toHaveLength(1)
})
it('disables submit button while loading — prevents double-submit', async () => {
let resolve!: (v: unknown) => void
vi.mocked(authApi.login).mockReturnValue(new Promise((r) => { resolve = r }))
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pass' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
})
resolve({ data: { access_token: 'tok' } })
})
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'tok' } } as never)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith('admin', 'secret')
})
})
it('stores token in authStore on successful login', async () => {
vi.mocked(authApi.login).mockResolvedValue({ data: { access_token: 'mytoken123' } } as never)
render(<LoginPage />)
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'correct' } })
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
await waitFor(() => {
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().token).toBe('mytoken123')
})
})
it('token persisted via sessionStorage — not localStorage', () => {
// The authStore uses createJSONStorage(() => sessionStorage)
// Verify the storage key exists in sessionStorage after login
render(<LoginPage />)
// Even before login, the store is backed by sessionStorage
expect(typeof sessionStorage).toBe('object')
// localStorage should NOT contain the auth token
expect(localStorage.getItem('homelable-auth')).toBeNull()
})
it('does not show error on initial render', () => {
render(<LoginPage />)
expect(screen.queryByText('Invalid username or password')).toBeNull()
})
it('requires username (HTML required attribute)', () => {
render(<LoginPage />)
const un = screen.getByLabelText('Username') as HTMLInputElement
expect(un.required).toBe(true)
})
it('requires password (HTML required attribute)', () => {
render(<LoginPage />)
const pw = screen.getByLabelText('Password') as HTMLInputElement
expect(pw.required).toBe(true)
})
})
@@ -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()
})
})
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import { CanvasContainer } from '../CanvasContainer'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
// Capture props passed to ReactFlow so we can test the callbacks
let rfProps: Record<string, unknown> = {}
vi.mock('@xyflow/react', () => ({
ReactFlow: (props: Record<string, unknown>) => {
rfProps = props
return <div data-testid="react-flow" />
},
Background: () => null,
Controls: () => null,
ControlButton: () => null,
BackgroundVariant: { Dots: 'dots' },
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', () => ({}))
function makeNode(id: string): Node<NodeData> {
return {
id,
type: 'server',
position: { x: 0, y: 0 },
data: { label: id, type: 'server', status: 'unknown', services: [] },
}
}
function makeEdge(id: string): Edge<EdgeData> {
return { id, source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
}
describe('CanvasContainer', () => {
beforeEach(() => {
rfProps = {}
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
useThemeStore.setState({ activeTheme: 'default' })
})
// ── Rendering ─────────────────────────────────────────────────────────────
it('renders without crashing', () => {
const { getByTestId } = render(<CanvasContainer />)
expect(getByTestId('react-flow')).toBeDefined()
})
it('passes nodes from store to ReactFlow', () => {
useCanvasStore.setState({ nodes: [makeNode('n1'), makeNode('n2')] })
render(<CanvasContainer />)
expect((rfProps.nodes as Node[]).length).toBe(2)
})
it('passes edges from store to ReactFlow', () => {
useCanvasStore.setState({
nodes: [makeNode('n1'), makeNode('n2')],
edges: [makeEdge('e1')],
})
render(<CanvasContainer />)
expect((rfProps.edges as Edge[]).length).toBe(1)
})
// ── Node click → selection ────────────────────────────────────────────────
it('calls setSelectedNode with node id on node click', () => {
const node = makeNode('n1')
useCanvasStore.setState({ nodes: [node] })
render(<CanvasContainer />)
;(rfProps.onNodeClick as (...args: unknown[]) => unknown)({} as MouseEvent, node)
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
})
// ── Pane click → deselect ─────────────────────────────────────────────────
it('calls setSelectedNode(null) on pane click', () => {
useCanvasStore.setState({ selectedNodeId: 'n1' })
render(<CanvasContainer />)
;(rfProps.onPaneClick as (...args: unknown[]) => unknown)()
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
})
// ── Edge double-click ─────────────────────────────────────────────────────
it('calls onEdgeDoubleClick prop when an edge is double-clicked', () => {
const onEdgeDoubleClick = vi.fn()
const edge = makeEdge('e1')
render(<CanvasContainer onEdgeDoubleClick={onEdgeDoubleClick} />)
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
expect(onEdgeDoubleClick).toHaveBeenCalledWith(edge)
})
it('does not throw when onEdgeDoubleClick is not provided', () => {
const edge = makeEdge('e1')
render(<CanvasContainer />)
expect(() => {
;(rfProps.onEdgeDoubleClick as (...args: unknown[]) => unknown)({} as MouseEvent, edge)
}).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 ─────────────────────────────────────────────────
it('isValidConnection returns false for self-connections', () => {
render(<CanvasContainer />)
const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean
expect(isValid({ source: 'n1', target: 'n1' })).toBe(false)
})
it('isValidConnection returns true for different nodes', () => {
render(<CanvasContainer />)
const isValid = rfProps.isValidConnection as (c: { source: string; target: string }) => boolean
expect(isValid({ source: 'n1', target: 'n2' })).toBe(true)
})
// ── onConnect prop passthrough ────────────────────────────────────────────
it('passes onConnect prop to ReactFlow', () => {
const onConnect = vi.fn()
render(<CanvasContainer onConnect={onConnect} />)
;(rfProps.onConnect as (...args: unknown[]) => unknown)({ source: 'a', target: 'b', sourceHandle: null, targetHandle: null })
expect(onConnect).toHaveBeenCalledOnce()
})
// ── onNodeDragStart prop passthrough ──────────────────────────────────────
it('passes onNodeDragStart prop to ReactFlow', () => {
const onNodeDragStart = vi.fn()
render(<CanvasContainer onNodeDragStart={onNodeDragStart} />)
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
})
// ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => {
render(<CanvasContainer />)
expect(rfProps.snapToGrid).toBe(true)
})
it('sets snapGrid to [8, 8]', () => {
render(<CanvasContainer />)
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)
})
})
+277 -29
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,48 +233,74 @@ 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)` } : {}),
} }
// Animated dot: slightly brighter + thicker than the base edge, travels source→target const animMode: 'none' | 'snake' | 'flow' | 'basic' =
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string) data?.animated === true || data?.animated === 'snake' ? 'snake' :
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2 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 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} />
{data?.animated && (
{animMode === 'basic' && (
<path <path
d={edgePath} d={edgePath}
fill="none" fill="none"
stroke={dotColor} stroke={strokeColor}
strokeWidth={dotWidth} 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' && (
<path
d={edgePath}
fill="none"
stroke={animColor}
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
strokeDasharray="20 10000" strokeDasharray="20 10000"
strokeLinecap="round" strokeLinecap="round"
style={{ pointerEvents: 'none' }} style={{ pointerEvents: 'none' }}
> >
{isBidirectional ? ( {isBidirectional ? (
<animate <animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
attributeName="stroke-dashoffset"
values="-10000;0;-10000"
keyTimes="0;0.5;1"
dur="20s"
repeatCount="indefinite"
/>
) : ( ) : (
<animate <animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
attributeName="stroke-dashoffset"
from="-10000"
to="0"
dur="10s"
repeatCount="indefinite"
/>
)} )}
</path> </path>
)} )}
{data?.label && ( {animMode === 'flow' && (
<EdgeLabelRenderer> <path
d={edgePath}
fill="none"
stroke={animColor}
strokeWidth={Math.max(3, (style.strokeWidth as number ?? 2) * 1.8)}
strokeDasharray="6 12"
strokeLinecap="round"
strokeOpacity={0.85}
style={{ pointerEvents: 'none' }}
>
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
</path>
)}
<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}`,
@@ -99,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
}
+144 -43
View File
@@ -1,19 +1,32 @@
import { createElement } from 'react' import { createElement, useEffect, useMemo } from 'react'
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
import { 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
} }
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { function formatStorage(gb: number): string {
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
return `${gb} GB`
}
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]
@@ -23,22 +36,38 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
const statusColor = theme.colors.statusColors[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
// 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-row items-center gap-2.5 px-2.5 py-2 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,
width: width ? '100%' : undefined,
height: height ? '100%' : undefined,
}} }}
> >
<NodeResizer
isVisible={selected}
minWidth={140}
minHeight={50}
lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
/>
<Handle <Handle
type="source" type="source"
position={Position.Top} position={Position.Top}
@@ -47,36 +76,95 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
/> />
<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 }} />
{/* Icon */} {/* Main row */}
<div <div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0" {/* Icon */}
style={{ <div
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor, className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
background: theme.colors.nodeIconBackground, style={{
}} color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
> background: theme.colors.nodeIconBackground,
{createElement(resolvedIcon, { size: 15 })} }}
>
{createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
<div className="flex flex-col min-w-0">
<div
className="text-xs font-medium leading-tight truncate"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label}
</div>
{data.ip && splitIps(data.ip).map((ip) => (
<div
key={ip}
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={ip}
>
{hideIp ? maskIp(ip) : ip}
</div>
))}
</div>
</div> </div>
{/* Details */} {/* Properties section (new system) */}
<div className="flex flex-col min-w-0"> {visibleProperties && visibleProperties.length > 0 && (
<div <>
className="text-xs font-medium leading-tight truncate max-w-[110px]" <div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
style={{ color: theme.colors.nodeLabelColor }} <div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
title={data.label} {visibleProperties.map((prop) => {
> const Icon = resolvePropertyIcon(prop.icon)
{data.label} return (
</div> <div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ip && ( {Icon && <Icon size={9} className="shrink-0" />}
<div <span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
className="font-mono text-[10px] truncate" <span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
style={{ color: theme.colors.nodeSubtextColor }} </div>
title={data.ip} )
> })}
{hideIp ? maskIp(data.ip) : data.ip}
</div> </div>
)} </>
</div> )}
{/* Legacy hardware section — fallback for nodes not yet migrated */}
{showLegacyHardware && (
<>
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
<div className="flex flex-col gap-1 px-2.5 py-1.5">
{(data.cpu_model || data.cpu_count != null) && (
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" />
{data.cpu_model && (
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
)}
{data.cpu_count != null && (
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
)}
</div>
)}
{(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && (
<span className="flex items-center gap-0.5">
<MemoryStick size={9} className="shrink-0" />
{formatStorage(data.ram_gb)}
</span>
)}
{data.disk_gb != null && (
<span className="flex items-center gap-0.5">
<HardDrive size={9} className="shrink-0" />
{formatStorage(data.disk_gb)}
</span>
)}
</div>
)}
</div>
</>
)}
{/* Status dot */} {/* Status dot */}
<div <div
@@ -85,13 +173,26 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
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,17 +27,58 @@ 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'
const borderStyle = rc.border_style ?? 'solid'
const borderWidth = rc.border_width ?? 2
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)' const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
const textColor = rc.text_color ?? '#e6edf3' const textColor = rc.text_color ?? '#e6edf3'
const textSize: number = rc.text_size ?? 12
const labelPosition: string = rc.label_position ?? 'inside'
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
const textPos = (rc.text_position ?? 'top-left') as TextPosition const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos] const posStyle = POSITION_STYLES[textPos]
const outsideJustify = textPos.includes('right') ? 'flex-end'
: (textPos.includes('center') || textPos === 'center') ? 'center'
: 'flex-start'
const isOutsideBottom = textPos.startsWith('bottom')
const outsideOffset = textSize + 16
const outsideVertical: React.CSSProperties = isOutsideBottom
? { bottom: -outsideOffset }
: { top: -outsideOffset }
const sharedTextStyle: React.CSSProperties = {
color: textColor,
fontFamily,
fontSize: textSize,
fontWeight: 500,
userSelect: 'none',
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
@@ -50,10 +92,20 @@ 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',
overflow: 'visible',
width: '100%', width: '100%',
height: '100%', height: '100%',
display: 'flex', display: 'flex',
@@ -61,22 +113,37 @@ 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 ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`, border: `${borderWidth}px ${borderStyle} ${borderColor}`,
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
borderRadius: 10, borderRadius: 10,
fontFamily,
color: textColor,
fontSize: 12,
fontWeight: 500,
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)
}} }}
> >
{data.label && ( {labelPosition === 'outside' && data.label && (
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}> <span
style={{
position: 'absolute',
...outsideVertical,
left: 0,
right: 0,
display: 'flex',
justifyContent: outsideJustify,
pointerEvents: 'none',
...sharedTextStyle,
}}
>
{data.label}
</span>
)}
{labelPosition === 'inside' && data.label && (
<span style={{ textAlign: posStyle.textAlign, ...sharedTextStyle }}>
{data.label} {data.label}
</span> </span>
)} )}
@@ -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, 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,4 +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 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, 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,6 +18,9 @@ export const nodeTypes = {
printer: PrinterNode, printer: PrinterNode,
computer: ComputerNode, computer: ComputerNode,
cpl: CplNode, cpl: CplNode,
docker_host: DockerHostNode,
docker_container: DockerContainerNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode, groupRect: GroupRectNode,
group: GroupNode,
} }
+42 -17
View File
@@ -10,22 +10,32 @@ 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' | 'basic' | 'snake' | 'flow'
function toAnimMode(v: EdgeData['animated']): AnimMode {
if (v === true || v === 'snake') return 'snake'
if (v === 'flow') return 'flow'
if (v === 'basic') return 'basic'
return 'none'
}
interface EdgeModalProps { interface EdgeModalProps {
open: boolean open: boolean
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() ?? '')
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color) const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier') const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
const [animated, setAnimated] = useState(initial?.animated ?? false) const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type] const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
@@ -37,7 +47,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined, vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor, custom_color: customColor,
path_style: pathStyle, path_style: pathStyle,
animated: animated || undefined, animated: animation !== 'none' ? animation : undefined,
}) })
onClose() onClose()
} }
@@ -115,20 +125,25 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Flow Animation</Label> <Label className="text-xs text-muted-foreground">Animation</Label>
<button <div className="flex rounded-md overflow-hidden border border-[#30363d]">
type="button" {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
onClick={() => setAnimated((a) => !a)} <button
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0" key={mode}
style={{ background: animated ? '#00d4ff' : '#30363d' }} type="button"
aria-pressed={animated} onClick={() => setAnimation(mode)}
> className="flex-1 py-1 text-xs capitalize transition-colors"
<span style={{
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform" background: animation === mode ? '#00d4ff22' : '#21262d',
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }} color: animation === mode ? '#00d4ff' : '#8b949e',
/> borderRight: i < 3 ? '1px solid #30363d' : undefined,
</button> }}
>
{mode === 'none' ? 'None' : mode === 'basic' ? 'Basic' : mode === 'snake' ? 'Snake' : 'Flow'}
</button>
))}
</div>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -162,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>
)
}
+175 -17
View File
@@ -5,23 +5,66 @@ 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 LabelPosition = 'inside' | 'outside'
export interface GroupRectFormData { export interface GroupRectFormData {
label: string label: string
font: string font: string
text_color: string text_color: string
text_position: TextPosition text_position: TextPosition
text_size: number
label_position: LabelPosition
border_color: string border_color: string
border_style: BorderStyle
border_width: number
background_color: string background_color: string
z_order: number z_order: number
} }
const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [
{ value: 'solid', label: 'Solid', preview: '───' },
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
{ value: 'dotted', label: 'Dotted', preview: '···' },
{ value: 'double', label: 'Double', preview: '═══' },
{ value: 'none', label: 'None', preview: ' ' },
]
const TEXT_SIZES: { value: number; label: string }[] = [
{ value: 10, label: '10' },
{ value: 12, label: '12' },
{ value: 14, label: '14' },
{ value: 16, label: '16' },
{ value: 18, label: '18' },
{ value: 20, label: '20' },
]
const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [
{ value: 'inside', label: 'Inside' },
{ value: 'outside', label: 'Outside' },
]
const BORDER_WIDTHS: { value: number; label: string }[] = [
{ value: 1, label: '1px' },
{ value: 2, label: '2px' },
{ value: 3, label: '3px' },
{ value: 4, label: '4px' },
{ value: 5, label: '5px' },
]
const DEFAULT_FORM: GroupRectFormData = { const DEFAULT_FORM: GroupRectFormData = {
label: '', label: '',
font: 'inter', font: 'inter',
text_color: '#e6edf3', text_color: '#e6edf3',
text_position: 'top-left', text_position: 'top-left',
text_size: 12,
label_position: 'inside',
border_color: '#00d4ff', border_color: '#00d4ff',
border_style: 'solid',
border_width: 2,
background_color: '#00d4ff0d', background_color: '#00d4ff0d',
z_order: 1, z_order: 1,
} }
@@ -53,7 +96,7 @@ interface GroupRectModalProps {
title?: string title?: string
} }
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) { export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) {
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial }) const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) => const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
@@ -133,27 +176,142 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
</div> </div>
</div> </div>
{/* Label position */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label Position</Label>
<div className="grid grid-cols-2 gap-1">
{LABEL_POSITIONS.map(({ value, label }) => {
const isSelected = form.label_position === value
return (
<button
key={value}
type="button"
onClick={() => set('label_position', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Colors */} {/* Colors */}
<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>
{/* Text size */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Size</Label>
<div className="grid grid-cols-6 gap-1">
{TEXT_SIZES.map(({ value, label }) => {
const isSelected = form.text_size === value
return (
<button
key={value}
type="button"
onClick={() => set('text_size', value)}
className="flex items-center justify-center h-8 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
fontSize: value,
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Border style */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Style</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_STYLES.map(({ value, label, preview }) => {
const isSelected = form.border_style === value
return (
<button
key={value}
type="button"
title={label}
onClick={() => set('border_style', value)}
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
<span className="font-mono text-[11px] leading-none">{preview}</span>
<span className="text-[9px]">{label}</span>
</button>
)
})}
</div>
</div>
{/* Border width */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Border Width</Label>
<div className="grid grid-cols-5 gap-1">
{BORDER_WIDTHS.map(({ value, label }) => {
const isSelected = form.border_width === value
return (
<button
key={value}
type="button"
onClick={() => set('border_width', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div> </div>
</div> </div>
@@ -191,7 +349,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
Cancel Cancel
</Button> </Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"> <Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
{title === 'Add Rectangle' ? 'Add' : 'Save'} {title === 'Add Zone' ? 'Add' : 'Save'}
</Button> </Button>
</div> </div>
</div> </div>
+158 -92
View File
@@ -1,17 +1,34 @@
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'
import { Input } from '@/components/ui/input' 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, 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_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][] const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const 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',
@@ -21,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,
} }
@@ -32,28 +49,40 @@ 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 set = (key: keyof NodeData, value: unknown) => const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!form.label?.trim()) return if (!form.label?.trim()) {
onSubmit(form) setLabelError(true)
return
}
setLabelError(false)
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">
@@ -63,25 +92,35 @@ 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_TYPES.map(([value, label]) => ( {NODE_TYPE_GROUPS.map((group, i) => (
<SelectItem key={value} value={value} className="text-sm"> <Fragment key={group.label}>
{label} {i > 0 && <SelectSeparator className="bg-[#30363d]" />}
</SelectItem> <SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type} value={type} className="text-sm pl-4">
{NODE_TYPE_LABELS[type]}
</SelectItem>
))}
</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 && (
@@ -98,80 +137,82 @@ 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>
<Input <Input
value={form.label ?? ''} value={form.label ?? ''}
onChange={(e) => set('label', e.target.value)} onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server" placeholder="My Server"
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
required
/> />
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div> </div>
{/* Hostname */} {/* Hostname */}
@@ -187,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>
@@ -201,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>
@@ -222,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>
@@ -243,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>
@@ -310,6 +355,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
)} )}
</div> </div>
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<Select
value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Notes */} {/* Notes */}
<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">Notes</Label> <Label className="text-xs text-muted-foreground">Notes</Label>
@@ -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>
@@ -0,0 +1,256 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { EdgeModal } from '../EdgeModal'
describe('EdgeModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when closed', () => {
const { container } = render(<EdgeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form when open', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Connect Nodes')).toBeDefined()
})
it('uses custom title when provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Link" />)
expect(screen.getByText('Edit Link')).toBeDefined()
})
// ── Submit button label ───────────────────────────────────────────────────
it('shows "Connect" button when onDelete is not provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Connect' })).toBeDefined()
})
it('shows "Save" button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeDefined()
})
// ── Default submit ────────────────────────────────────────────────────────
it('calls onSubmit with default ethernet type', () => {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].type).toBe('ethernet')
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onSubmit with label when filled', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
})
it('omits label from payload when empty', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
})
// ── VLAN ID field ─────────────────────────────────────────────────────────
it('does not show VLAN ID field for ethernet type', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByPlaceholderText('e.g. 20')).toBeNull()
})
it('submits integer vlan_id when type is vlan', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'vlan', vlan_id: 20 }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].vlan_id).toBe(20)
})
it('omits vlan_id from payload for non-vlan types', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].vlan_id).toBeUndefined()
})
// ── Path style ────────────────────────────────────────────────────────────
it('defaults to bezier path style', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('bezier')
})
it('switches path style to smooth on click', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Smooth step'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
})
// ── Animation select ──────────────────────────────────────────────────────
it('animation defaults to None — animated omitted from payload', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
})
it('selecting Snake sends animated: "snake"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Snake'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
it('selecting Flow sends animated: "flow"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Flow'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
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', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Snake'))
fireEvent.click(screen.getByText('None'))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
})
it('pre-fills animation from initial "snake" string', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'snake' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
it('pre-fills animation from legacy initial true (backward compat)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: true }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
})
// ── Pre-fill ──────────────────────────────────────────────────────────────
it('pre-fills label from initial prop', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
expect(input.value).toBe('trunk')
})
it('pre-fills path style from initial prop', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ path_style: 'smooth' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
})
// ── Cancel & Delete ───────────────────────────────────────────────────────
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('shows Delete button when onDelete is provided', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Delete' })).toBeDefined()
})
it('does not show Delete button without onDelete', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull()
})
it('calls onDelete and onClose when Delete is clicked', () => {
const onDelete = vi.fn()
const onClose = vi.fn()
render(<EdgeModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onDelete).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()
})
})
@@ -13,14 +13,15 @@ describe('GroupRectModal', () => {
it('renders form fields when open', () => { it('renders form fields when open', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />) render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined() expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
expect(screen.getByText('Add Rectangle')).toBeDefined() expect(screen.getByText('Add Zone')).toBeDefined()
expect(screen.getByText('Text Position')).toBeDefined() expect(screen.getByText('Text Position')).toBeDefined()
expect(screen.getByText('Border Width')).toBeDefined()
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined() expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
}) })
it('renders Edit Rectangle title when provided', () => { it('renders Edit Zone title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />) render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
expect(screen.getByText('Edit Rectangle')).toBeDefined() expect(screen.getByText('Edit Zone')).toBeDefined()
}) })
it('calls onSubmit with form data on submit', () => { it('calls onSubmit with form data on submit', () => {
@@ -80,4 +81,230 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_position).toBe('bottom-right') expect(submitted.text_position).toBe('bottom-right')
}) })
it('renders Border Style section', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Border Style')).toBeDefined()
expect(screen.getByTitle('Solid')).toBeDefined()
expect(screen.getByTitle('Dashed')).toBeDefined()
expect(screen.getByTitle('Dotted')).toBeDefined()
expect(screen.getByTitle('Double')).toBeDefined()
expect(screen.getByTitle('None')).toBeDefined()
})
it('defaults border_style to solid', () => {
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.border_style).toBe('solid')
})
it('selects border style on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByTitle('Dashed'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('dashed')
})
it('pre-fills border_style from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ border_style: 'dotted' }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('dotted')
})
it('renders Label Position section with inside/outside options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Label Position')).toBeDefined()
expect(screen.getByText('Inside')).toBeDefined()
expect(screen.getByText('Outside')).toBeDefined()
})
it('defaults label_position to inside', () => {
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.label_position).toBe('inside')
})
it('selects outside label position on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Outside'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('outside')
})
it('pre-fills label_position from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ label_position: 'outside' }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label_position).toBe('outside')
})
it('renders Text Size section with 6 options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Text Size')).toBeDefined()
expect(screen.getByText('10')).toBeDefined()
expect(screen.getByText('20')).toBeDefined()
})
it('defaults text_size to 12', () => {
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.text_size).toBe(12)
})
it('selects text size on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('18'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(18)
})
it('pre-fills text_size from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ text_size: 16 }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_size).toBe(16)
})
it('renders Border Width section with 5 options', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByText('Border Width')).toBeDefined()
expect(screen.getByText('1px')).toBeDefined()
expect(screen.getByText('3px')).toBeDefined()
expect(screen.getByText('5px')).toBeDefined()
})
it('defaults border_width to 2', () => {
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.border_width).toBe(2)
})
it('selects border width on click', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('4px'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(4)
})
it('pre-fills border_width from initial prop', () => {
const onSubmit = vi.fn()
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={onSubmit}
initial={{ border_width: 5 }}
/>
)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_width).toBe(5)
})
it('toggles border style — clicking selected style deselects back to solid', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByTitle('Dotted'))
fireEvent.click(screen.getByTitle('Solid'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid')
})
it('shows opacity sliders for all three color fields', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
const sliders = screen.getAllByRole('slider')
expect(sliders).toHaveLength(3)
})
it('default background_color is 8-digit hex with low alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.background_color).toBe('#00d4ff0d')
expect(submitted.background_color.length).toBe(9)
})
it('moving background opacity slider updates background_color alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// background slider is the third one (Text, Border, Background)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[2], { target: { value: '50' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
// alpha 50% → 0x80 = 128
expect(submitted.background_color).toBe('#00d4ff80')
})
it('moving border opacity slider to 0 makes border fully transparent', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[1], { target: { value: '0' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_color).toBe('#00d4ff00')
})
it('pre-fills opacity from 8-digit initial background_color', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ background_color: '#ff6e0080' }}
/>
)
const sliders = screen.getAllByRole('slider')
expect((sliders[2] as HTMLInputElement).value).toBe('50')
})
it('shows opacity percentage in label', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
// Background default is 5% opacity
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
})
}) })
@@ -0,0 +1,362 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
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', () => {
// ── Visibility ────────────────────────────────────────────────────────
it('renders nothing when closed', () => {
const { container } = render(<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form fields when open', () => {
renderModal()
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
expect(screen.getByText('Add Node')).toBeDefined()
})
it('shows "Add" button for default title', () => {
renderModal()
expect(screen.getByRole('button', { name: 'Add' })).toBeDefined()
})
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(screen.getByText('Label is required')).toBeDefined()
})
it('blocks submit when label is whitespace only', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: ' ' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).not.toHaveBeenCalled()
})
it('clears label error when user starts typing', () => {
renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
expect(screen.queryByText('Label is required')).toBeNull()
})
// ── Form submission ───────────────────────────────────────────────────
it('calls onSubmit and onClose with form data on valid submit', () => {
const { onSubmit, onClose } = renderModal({ initial: BASE })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect(onSubmit).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')
})
it('submits updated hostname, IP and notes', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'nas.local' } })
fireEvent.change(screen.getByPlaceholderText('192.168.1.x, 2001:db8::1'), { target: { value: '10.0.0.1' } })
fireEvent.change(screen.getByPlaceholderText('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('resets form values when reopened in Add mode', () => {
const onClose = vi.fn()
const onSubmit = vi.fn()
const { rerender } = render(<NodeModal key="open-1" open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Temp Node' } })
fireEvent.change(screen.getByPlaceholderText('server.lan'), { target: { value: 'temp.local' } })
rerender(<NodeModal key="closed" open={false} onClose={onClose} onSubmit={onSubmit} />)
rerender(<NodeModal key="open-2" open onClose={onClose} onSubmit={onSubmit} />)
expect((screen.getByPlaceholderText('My Server') as HTMLInputElement).value).toBe('')
expect((screen.getByPlaceholderText('server.lan') as HTMLInputElement).value).toBe('')
})
it('submits check_target', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_target).toBe('http://192.168.1.10:8080')
})
// ── Type selector ─────────────────────────────────────────────────────
it('pre-fills type from initial', () => {
renderModal({ initial: { ...BASE, type: 'router' } })
expect(selects()[0].value).toBe('router')
})
it('changes type and submits it', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[0], { target: { value: 'nas' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).type).toBe('nas')
})
// ── Check method ──────────────────────────────────────────────────────
it('pre-fills check_method from initial', () => {
renderModal({ initial: { ...BASE, check_method: 'http' } })
expect(selects()[1].value).toBe('http')
})
it('changes check_method and submits it', () => {
const { onSubmit } = renderModal({ initial: BASE })
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')
})
// ── 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)
})
})
@@ -0,0 +1,164 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { PendingDeviceModal, type PendingDevice } from '../PendingDeviceModal'
function makeDevice(overrides: Partial<PendingDevice> = {}): PendingDevice {
return {
id: 'dev-1',
ip: '192.168.1.100',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pve.local',
os: 'Linux',
services: [],
suggested_type: 'server',
status: 'pending',
discovered_at: '2024-01-15T10:30:00Z',
...overrides,
}
}
describe('PendingDeviceModal', () => {
// ── Visibility ────────────────────────────────────────────────────────────
it('renders nothing when device is null', () => {
const { container } = render(
<PendingDeviceModal device={null} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders dialog when device is provided', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByRole('dialog')).toBeDefined()
})
// ── Device info display ───────────────────────────────────────────────────
it('shows hostname as title when available', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: 'myserver.local' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// hostname appears in both title and info row — check at least one match
expect(screen.getAllByText('myserver.local').length).toBeGreaterThan(0)
})
it('falls back to IP as title when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// IP appears in both title and info row when hostname is absent
expect(screen.getAllByText('192.168.1.100').length).toBeGreaterThan(0)
})
it('shows IP address', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('192.168.1.100')).toBeDefined()
})
it('shows MAC address when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeDefined()
})
it('shows OS when present', () => {
render(
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Linux')).toBeDefined()
})
it('does not show hostname row when hostname is null', () => {
render(
<PendingDeviceModal device={makeDevice({ hostname: null })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
// "Hostname" label should not appear in the info rows
expect(screen.queryByText('Hostname')).toBeNull()
})
it('shows suggested type when present', () => {
render(
<PendingDeviceModal device={makeDevice({ suggested_type: 'proxmox' })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('proxmox')).toBeDefined()
})
// ── Services ──────────────────────────────────────────────────────────────
it('shows "No services detected" when services list is empty', () => {
render(
<PendingDeviceModal device={makeDevice({ services: [] })} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('No services detected')).toBeDefined()
})
it('shows service count and details', () => {
const device = makeDevice({
services: [
{ port: 80, protocol: 'tcp', service_name: 'HTTP', category: 'web' },
{ port: 443, protocol: 'tcp', service_name: 'HTTPS', category: 'web' },
],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('Services found (2)')).toBeDefined()
expect(screen.getByText('HTTP')).toBeDefined()
expect(screen.getByText('HTTPS')).toBeDefined()
expect(screen.getByText('80')).toBeDefined()
expect(screen.getByText('443')).toBeDefined()
})
it('shows service category when present', () => {
const device = makeDevice({
services: [{ port: 8006, protocol: 'tcp', service_name: 'Proxmox Web', category: 'hypervisor' }],
})
render(
<PendingDeviceModal device={device} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
)
expect(screen.getByText('hypervisor')).toBeDefined()
})
// ── Actions ───────────────────────────────────────────────────────────────
it('calls onApprove with the device when Approve is clicked (parent controls close on success)', () => {
const device = makeDevice()
const onApprove = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={onApprove} onHide={vi.fn()} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
expect(onApprove).toHaveBeenCalledWith(device)
expect(onClose).not.toHaveBeenCalled()
})
it('calls onHide with the device and onClose when Hide is clicked', () => {
const device = makeDevice()
const onHide = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={onHide} onIgnore={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Hide' }))
expect(onHide).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
it('calls onIgnore with the device and onClose when Delete is clicked', () => {
const device = makeDevice()
const onIgnore = vi.fn()
const onClose = vi.fn()
render(
<PendingDeviceModal device={device} onClose={onClose} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={onIgnore} />
)
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
expect(onIgnore).toHaveBeenCalledWith(device)
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ScanConfigModal } from '../ScanConfigModal'
vi.mock('@/api/client', () => ({
scanApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
trigger: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
const defaultConfig = { data: { ranges: ['192.168.1.0/24'] } }
describe('ScanConfigModal', () => {
beforeEach(() => {
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
vi.mocked(scanApi.saveConfig).mockReset()
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.error).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<ScanConfigModal open={false} onClose={vi.fn()} onScanNow={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('loads config from API on open', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => {
expect(scanApi.getConfig).toHaveBeenCalledOnce()
})
const input = await screen.findByDisplayValue('192.168.1.0/24')
expect(input).toBeDefined()
})
it('adds a new empty range on "Add range" click', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByText('Add range'))
const inputs = screen.getAllByPlaceholderText('192.168.1.0/24')
expect(inputs).toHaveLength(2)
})
it('delete button disabled when only one range', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
const trashButtons = document.querySelectorAll('button[disabled]')
expect(trashButtons.length).toBeGreaterThan(0)
})
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'] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await screen.findByDisplayValue('192.168.1.0/24')
const trashButtons = screen.getAllByRole('button').filter((b) => !b.hasAttribute('disabled') && b.querySelector('svg'))
expect(trashButtons.length).toBeGreaterThanOrEqual(2)
})
it('shows error toast and does not save when all ranges are empty', async () => {
vi.mocked(scanApi.getConfig).mockResolvedValue({ data: { ranges: [''] } } as never)
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Add at least one IP range')
})
expect(scanApi.saveConfig).not.toHaveBeenCalled()
})
it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
const onScanNow = vi.fn()
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
expect(scanApi.trigger).toHaveBeenCalledOnce()
expect(onScanNow).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
})
it('calls onClose when Cancel is clicked', async () => {
const onClose = vi.fn()
render(<ScanConfigModal open onClose={onClose} onScanNow={vi.fn()} />)
await waitFor(() => expect(scanApi.getConfig).toHaveBeenCalled())
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('strips whitespace from ranges before scanning', async () => {
render(<ScanConfigModal open onClose={vi.fn()} onScanNow={vi.fn()} />)
const input = await screen.findByDisplayValue('192.168.1.0/24')
fireEvent.change(input, { target: { value: ' 10.0.0.0/8 ' } })
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
await waitFor(() => {
expect(scanApi.saveConfig).toHaveBeenCalledWith(
expect.objectContaining({ ranges: ['10.0.0.0/8'] })
)
})
})
})

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