Compare commits

...

87 Commits

Author SHA1 Message Date
Pouzor 2cc97a6de9 bump: version 1.12.0 2026-04-24 15:33:09 +02:00
Remy 29a2ef1b20 Merge pull request #94 from findthelorax/bug/bezier-label
fix: bezier label
2026-04-24 15:23:54 +02:00
Pouzor 0c836e0575 Merge remote-tracking branch 'origin/main' into bug/bezier-label 2026-04-24 15:04:05 +02:00
Remy 6518eb313b Merge pull request #108 from findthelorax/fix/modal-accessibility
fix: modal accessibility
2026-04-24 14:48:52 +02:00
Pouzor 09a591f5f4 fix: move IP address comma-separated hint below input to prevent label wrapping 2026-04-24 12:08:56 +02:00
Pouzor 988b804b90 fix: subtle hover border (#8b949e) and remove redundant tabIndex on button elements 2026-04-24 12:01:30 +02:00
Pouzor 6fa0ada325 merge: resolve EdgeModal conflict — keep CSS module classes + SelectValue label fix 2026-04-24 11:50:30 +02:00
Pouzor 17613f42d1 fix: style apply-to-existing buttons as primary blue actions 2026-04-24 11:42:31 +02:00
Pouzor 0a44b69c4e fix: add default size hint (min 140x50, auto) in custom style editor 2026-04-24 11:42:31 +02:00
Pouzor 84235d81bf fix: show edge type label in select trigger after selection 2026-04-24 11:42:31 +02:00
Pouzor 31b61904ac fix: add setCustomStyle to useEffect dependency array 2026-04-24 11:42:31 +02:00
Pouzor babbcb1dc5 feat: add custom style theme with per-type node/edge style editor
- New 'custom' ThemeId with dedicated ThemeCard + pencil edit button
- CustomStyleModal: two-column editor for all node types (excl. groupRect/group) and edge types
  - Per node: border, background, icon color + opacity, default size (w/h)
  - Per edge: color + opacity, path style, animation
  - 'Apply to existing [Type]' per type, 'Apply All to Canvas' footer action
- canvasStore: applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles actions
- themeStore: customStyle state + setCustomStyle action
- Backend: custom_style JSON column on canvas_state (ALTER TABLE migration), saved/loaded with canvas
- App.tsx: custom_style included in save payload, restored on load (API + standalone)
- Tests: +8 frontend (themeStore + canvasStore), +3 backend (canvas API)
2026-04-24 11:42:31 +02:00
findthelorax 12fde681ba removed closeIconProps on edge modal 2026-04-23 22:57:36 -04:00
Brett Ferrante 63922f0841 Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-23 20:06:50 -04:00
findthelorax 896cd4fa21 made a lot of accessibility fixes for the modalsincluding pointers, keyboard navigation and consistent border radius 2026-04-23 20:06:10 -04:00
findthelorax 18f9bb7bdf allow the enter key to apply the currently selected style 2026-04-24 00:42:00 +02:00
findthelorax da287d459c allow for keyboard navigation and adjusted card height to match regardless of text inside 2026-04-24 00:42:00 +02:00
findthelorax adb2088752 revert: restore workflow to upstream version 2026-04-24 00:42:00 +02:00
findthelorax cd0e08fb91 fixed canvas style options to fit better and be max 50vw 2026-04-24 00:42:00 +02:00
Brett Ferrante 3ccdde0bea Update Docker image references to use repository owner 2026-04-24 00:42:00 +02:00
Lucas Van Vonderen d0a49d0a0d fix(mcp): mount session manager via Starlette Mount to avoid double response.start
StreamableHTTPSessionManager is an ASGI app — it sends its own
http.response.start and http.response.body messages via the scope/receive/send
triple. Wrapping it inside a @app.api_route FastAPI handler causes FastAPI
to try to finalize the response after the handler returns, emitting a
second http.response.start. uvicorn rejects this with:

    RuntimeError: Unexpected ASGI message 'http.response.start' sent,
    after response already completed.

Every POST /mcp raises, making the server unreachable from any MCP client
(tested with Claude Code 2.x against mcp==1.27.0).

Fix: mount the session manager as a Starlette Mount so it owns the
response cycle directly. Auth middleware still applies because
add_middleware attaches at the app level, wrapping all mounted sub-apps.
2026-04-24 00:34:55 +02:00
findthelorax 31b5bc4515 fixed accessibility and keyboard navigation for edit node and connection modals 2026-04-23 16:00:55 -04:00
Brett Ferrante b05d70663c Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-22 20:49:11 -04:00
Brett Ferrante 4e68af7cac Merge branch 'Pouzor:main' into main 2026-04-22 20:48:47 -04:00
Pouzor 528c362633 bump: version 1.11.0 2026-04-23 02:38:31 +02:00
Pouzor bfe520cd49 fix: use absolute positioning for status dot to prevent IP truncation 2026-04-23 02:35:23 +02:00
Pouzor 29c97ae501 fix: align status dot to top and restore gap in ProxmoxGroupNode header 2026-04-23 02:35:23 +02:00
findthelorax 4ecd241bf4 enlarged node resizer border for easier resizing 2026-04-23 02:35:23 +02:00
findthelorax 96786f155b aligned all status icon styling 2026-04-23 02:35:23 +02:00
Pouzor 1d6127fed3 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:55:14 +02:00
findthelorax 26633f760d aligned services and path styling 2026-04-23 00:55:14 +02:00
findthelorax 745002593f 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:55:14 +02:00
findthelorax 1a978c5e51 prioritize service name and port, truncate path or hide when necessary 2026-04-23 00:55:14 +02:00
findthelorax 16adff5cff prioritize service name, truncate path or hide when necessary 2026-04-23 00:55:14 +02:00
findthelorax 45b0965fb7 path max width set to 80px 2026-04-23 00:55:14 +02:00
findthelorax f1bcd6ef78 restore: tooltip.tsx 2026-04-23 00:55:14 +02:00
findthelorax 26be37f731 aligned services and path styling 2026-04-23 00:55:14 +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
Brett Ferrante bfd7ccd36c Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-21 10:47:39 -04:00
Brett Ferrante ada30311ed Merge branch 'Pouzor:main' into main 2026-04-21 10:11:55 -04: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
Brett Ferrante 6ead20125d Merge branch 'Pouzor:main' into main 2026-04-21 09:51:22 -04: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
findthelorax 8626fb2ca4 revert: restore workflow to upstream version 2026-04-20 23:28:11 -04:00
findthelorax 5952274c27 adding in path geometry for the add point + 2026-04-20 23:26:25 -04:00
findthelorax 9c035e2be2 adding in path geometry for the add point + 2026-04-20 23:24:26 -04:00
findthelorax cad3add223 bug: fixed an issue with the label and a bezier path with multiple points 2026-04-20 23:24:26 -04:00
Brett Ferrante fc888629c3 Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-20 23:17:05 -04:00
Brett Ferrante 6a21cc729e Merge pull request #16 from findthelorax/findthelorax-workflow-owner-patch
Update Docker image references to use repository owner
2026-04-20 09:36:24 -04:00
Brett Ferrante 06370529c6 Update Docker image references to use repository owner 2026-04-20 09:35:53 -04: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
Brett Ferrante 892710faac Merge branch 'Pouzor:main' into bug/bezier-label 2026-04-18 22:27:02 -04:00
findthelorax 431fb47498 bug: fixed an issue with the label and a bezier path with multiple points 2026-04-17 20:28:51 -04:00
57 changed files with 3441 additions and 607 deletions
+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.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Remy Jardinet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -1 +1 @@
1.10.0 1.12.0
+4 -2
View File
@@ -25,6 +25,7 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
nodes=[NodeResponse.model_validate(n) for n in nodes], nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges], edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport, viewport=viewport,
custom_style=state.custom_style if state else None,
) )
@@ -67,13 +68,14 @@ async def save_canvas(
else: else:
db.add(Edge(**edge_data.model_dump())) db.add(Edge(**edge_data.model_dump()))
# Upsert viewport # Upsert viewport + custom style
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, 1)
if state: if state:
state.viewport = body.viewport state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc) state.saved_at = datetime.now(timezone.utc)
else: else:
db.add(CanvasState(id=1, viewport=body.viewport)) db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
await db.commit() await db.commit()
return {"saved": True} return {"saved": True}
+38 -17
View File
@@ -41,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)
@@ -89,12 +97,10 @@ async def clear_pending(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, int]: ) -> dict[str, int]:
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) from sqlalchemy import delete as sa_delete
devices = result.scalars().all() result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
for device in devices:
await db.delete(device)
await db.commit() await db.commit()
return {"deleted": len(devices)} return {"deleted": result.rowcount}
@router.get("/hidden", response_model=list[PendingDeviceResponse]) @router.get("/hidden", response_model=list[PendingDeviceResponse])
@@ -116,7 +122,7 @@ async def bulk_approve_devices(
) )
) )
devices = result.scalars().all() devices = result.scalars().all()
node_ids: list[str] = [] created_nodes: list[Node] = []
for device in devices: for device in devices:
device.status = "approved" device.status = "approved"
node = Node( node = Node(
@@ -128,9 +134,11 @@ async def bulk_approve_devices(
services=device.services or [], services=device.services or [],
) )
db.add(node) db.add(node)
node_ids.append(node.id) created_nodes.append(node)
await db.commit() 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] approved_device_ids = [d.id for d in devices]
await db.commit()
return { return {
"approved": len(node_ids), "approved": len(node_ids),
"node_ids": node_ids, "node_ids": node_ids,
@@ -166,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")
@@ -212,10 +231,12 @@ async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
@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.save_overrides() settings.save_overrides()
return payload return payload
except Exception as exc: except Exception as exc:
settings.scanner_ranges = previous
logger.error("Failed to save scan config: %s", exc) logger.error("Failed to save scan config: %s", exc)
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
+2
View File
@@ -84,6 +84,8 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError): with suppress(OperationalError):
rows = await conn.exec_driver_sql( rows = await conn.exec_driver_sql(
+1
View File
@@ -78,6 +78,7 @@ class CanvasState(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2
View File
@@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = [] nodes: list[NodeSave] = []
edges: list[EdgeSave] = [] edges: list[EdgeSave] = []
viewport: dict[str, Any] = {} viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None
class CanvasStateResponse(BaseModel): class CanvasStateResponse(BaseModel):
nodes: list[NodeResponse] nodes: list[NodeResponse]
edges: list[EdgeResponse] edges: list[EdgeResponse]
viewport: dict[str, Any] viewport: dict[str, Any]
custom_style: dict[str, Any] | None = None
+48
View File
@@ -453,6 +453,15 @@ async def test_save_canvas_persists_services_and_notes(client: AsyncClient, head
assert node["notes"] == "My NAS device" 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): async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
n1 = node_payload(check_method="ping", check_target="192.168.1.1") 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) await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
@@ -548,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
edge = canvas["edges"][0] edge = canvas["edges"][0]
assert edge["label"] == "updated" assert edge["label"] == "updated"
assert edge["custom_color"] == "#ff0000" assert edge["custom_color"] == "#ff0000"
# ── custom_style ──────────────────────────────────────────────────────────────
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
custom_style = {
"nodes": {
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
},
"edges": {
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
},
}
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
assert res.status_code == 200
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert canvas["custom_style"] is not None
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/canvas", headers=headers)
assert res.status_code == 200
assert res.json()["custom_style"] is None
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
assert "proxmox" in canvas["custom_style"]["nodes"]
assert "server" not in canvas["custom_style"]["nodes"]
+2 -2
View File
@@ -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 ---
@@ -478,6 +477,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
data = res.json() data = res.json()
assert data["approved"] == 2 assert data["approved"] == 2
assert len(data["node_ids"]) == 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 len(data["device_ids"]) == 2
assert data["skipped"] == 0 assert data["skipped"] == 0
# Pending list should now be empty # Pending list should now be empty
+413 -3
View File
@@ -1,18 +1,19 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.8.2", "version": "1.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.8.2", "version": "1.10.2",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
@@ -2011,6 +2012,415 @@
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-escape-keydown": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -3024,7 +3434,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.10.0", "version": "1.12.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -19,6 +19,7 @@
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@xyflow/react": "^12.10.1", "@xyflow/react": "^12.10.1",
"axios": "^1.13.6", "axios": "^1.13.6",
+44 -30
View File
@@ -5,7 +5,7 @@ import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { generateMarkdownTable } from '@/utils/exportMarkdown' import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export' import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml' import { parseYamlToCanvas } from '@/utils/importYaml'
import { TooltipProvider } from '@/components/ui/tooltip' import { TooltipProvider } from '@/components/ui/tooltip'
@@ -29,16 +29,17 @@ import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client' import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
export default function App() { export default function App() {
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, 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, customStyle, setCustomStyle } = useThemeStore()
useStatusPolling() useStatusPolling()
@@ -53,25 +54,26 @@ 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 () => {
try { try {
if (STANDALONE) { if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme })) localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return return
} }
const nodesToSave = nodes.map(serializeNode) const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge) const edgesToSave = edges.map(serializeEdge)
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } }) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
} catch { } catch {
toast.error('Save failed') toast.error('Save failed')
} }
}, [nodes, edges, markSaved, activeTheme]) }, [nodes, edges, markSaved, activeTheme, customStyle])
// Keep a ref so the keydown handler always calls the latest version // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
@@ -83,8 +85,9 @@ export default function App() {
try { try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) { if (saved) {
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved) const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
if (theme_id) setTheme(theme_id) if (theme_id) setTheme(theme_id)
if (custom_style) setCustomStyle(custom_style)
loadCanvas(savedNodes, savedEdges) loadCanvas(savedNodes, savedEdges)
} else { } else {
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
@@ -99,23 +102,24 @@ export default function App() {
.then((res) => { .then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) { if (apiNodes.length > 0) {
// Build a map of proxmox container mode to know if children should be nested // Build a map of container mode nodes to know if children should be nested
const proxmoxContainerMap = new Map<string, boolean>( const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[]) (apiNodes as ApiNode[])
.filter((n) => n.type === 'proxmox' || n.type === 'group') .filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) .map((n) => [n.id, true])
) )
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const savedTheme = res.data.viewport?.theme_id const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme) if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(rfNodes, rfEdges) loadCanvas(rfNodes, rfEdges)
} else { } else {
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}) })
.catch(() => loadCanvas(demoNodes, demoEdges)) .catch(() => loadCanvas(demoNodes, demoEdges))
}, [isAuthenticated, loadCanvas, setTheme]) }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
// Keep refs for store actions so keydown handler is always up-to-date without re-registering // Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo) const undoRef = useRef(undo)
@@ -150,7 +154,7 @@ export default function App() {
const handleAddNode = useCallback((data: Partial<NodeData>) => { const handleAddNode = useCallback((data: Partial<NodeData>) => {
snapshotHistory() snapshotHistory()
const id = generateUUID() const id = generateUUID()
const isProxmox = data.type === 'proxmox' const isContainerNode = data.container_mode === true
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
// Children position is relative to parent; place near top-left with padding // Children position is relative to parent; place near top-left with padding
const position = parentNode const position = parentNode
@@ -163,7 +167,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}"`)
@@ -241,13 +245,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) {
@@ -305,15 +309,10 @@ export default function App() {
} }
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved]) }, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
const handleExport = useCallback(async () => { const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow') const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return } if (!el) { toast.error('Canvas not ready'); return }
try { setExportModalOpen(true)
await exportToPng(el)
toast.success('Exported as PNG')
} catch {
toast.error('Export failed')
}
}, []) }, [])
const handleEdgeConnect = useCallback((connection: Connection) => { const handleEdgeConnect = useCallback((connection: Connection) => {
@@ -324,15 +323,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 })
} }
} }
@@ -422,11 +425,14 @@ export default function App() {
</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 */}
@@ -437,7 +443,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
@@ -531,6 +539,12 @@ export default function App() {
/> />
<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>
+1
View File
@@ -33,6 +33,7 @@ export const canvasApi = {
nodes: object[] nodes: object[]
edges: object[] edges: object[]
viewport: object viewport: object
custom_style?: object | null
}) => api.post('/canvas/save', payload), }) => api.post('/canvas/save', payload),
} }
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils' import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils'
describe('buildWaypointPath — bezier (default)', () => { describe('buildWaypointPath — bezier (default)', () => {
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => { it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
expect(idx).toBe(2) expect(idx).toBe(2)
}) })
}) })
describe('getWaypointLabelPosition', () => {
it('uses the routed midpoint for a symmetric bezier waypoint path', () => {
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0)
expect(point.x).toBeCloseTo(50, 0)
expect(point.y).toBeCloseTo(100, 0)
})
it('uses the routed midpoint for a smooth waypoint path', () => {
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
expect(point.x).toBeCloseTo(50, 0)
expect(point.y).toBeCloseTo(50, 0)
})
it('falls back to the source point when the path is degenerate', () => {
const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth')
expect(point).toEqual({ x: 10, y: 20 })
})
})
describe('getAddWaypointHandlePosition', () => {
it('places bezier add handle on the rendered curved segment', () => {
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier')
expect(point.x).toBeCloseTo(21.875, 3)
expect(point.y).toBeCloseTo(56.25, 3)
})
it('keeps smooth add handle at straight segment midpoint', () => {
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth')
expect(point).toEqual({ x: 50, y: 50 })
})
})
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { buildWaypointPath, snap45, snap45both } from './waypointUtils' import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -161,9 +161,9 @@ function segmentMidpoints(
const isSmooth = pathStyle === 'smooth' const isSmooth = pathStyle === 'smooth'
return pts.slice(0, -1).map((a, i) => { return pts.slice(0, -1).map((a, i) => {
const b = pts[i + 1] const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
let mx = (a.x + b.x) / 2 let mx = base.x
const my = (a.y + b.y) / 2 const my = base.y
// For smooth style with no existing waypoints, bias the single + handle onto // For smooth style with no existing waypoints, bias the single + handle onto
// the source handle axis so clicking it creates a perpendicular exit. // the source handle axis so clicking it creates a perpendicular exit.
@@ -205,8 +205,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle) ? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: autoPath : autoPath
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX const labelPosition = hasWaypoints
const midY = (sourceY + targetY) / 2 ? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
: { x: labelX, y: (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
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
<div <div
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
style={{ style={{
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`, transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}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}`,
@@ -72,6 +72,216 @@ export function buildWaypointPath(
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts) return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
} }
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
return {
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
}
}
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
const mt = 1 - t
return {
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
}
}
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
const mt = 1 - t
return {
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
}
}
function approximateLength(pointAt: (t: number) => Waypoint, steps = 24): number {
let length = 0
let prev = pointAt(0)
for (let step = 1; step <= steps; step++) {
const next = pointAt(step / steps)
length += Math.hypot(next.x - prev.x, next.y - prev.y)
prev = next
}
return length
}
type PathSegment = {
length: number
pointAt: (t: number) => Waypoint
}
function buildBezierSegments(pts: Waypoint[]): PathSegment[] {
if (pts.length < 2) return []
return pts.slice(0, -1).map((_, 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 cp1 = {
x: p1.x + (p2.x - p0.x) / 6,
y: p1.y + (p2.y - p0.y) / 6,
}
const cp2 = {
x: p2.x - (p3.x - p1.x) / 6,
y: p2.y - (p3.y - p1.y) / 6,
}
const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t)
return {
length: approximateLength(pointAt),
pointAt,
}
})
}
function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] {
if (pts.length < 2) return []
if (pts.length === 2) {
const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t)
return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }]
}
const segments: PathSegment[] = []
let cursor = pts[0]
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) {
const start = { x: cursor.x, y: cursor.y }
const end = { x: curr.x, y: curr.y }
const lineToCurr = (t: number) => interpolateLine(start, end, t)
segments.push({
length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y),
pointAt: lineToCurr,
})
cursor = curr
continue
}
const r = Math.min(radius, len1 / 2, len2 / 2)
const before = {
x: curr.x - (dx1 / len1) * r,
y: curr.y - (dy1 / len1) * r,
}
const after = {
x: curr.x + (dx2 / len2) * r,
y: curr.y + (dy2 / len2) * r,
}
const lineStart = { x: cursor.x, y: cursor.y }
const lineEnd = { x: before.x, y: before.y }
const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t)
segments.push({
length: Math.hypot(before.x - cursor.x, before.y - cursor.y),
pointAt: lineToBefore,
})
const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t)
segments.push({
length: approximateLength(curveAroundCorner),
pointAt: curveAroundCorner,
})
cursor = after
}
const targetStart = { x: cursor.x, y: cursor.y }
const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t)
segments.push({
length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y),
pointAt: lineToTarget,
})
return segments
}
export function getWaypointLabelPosition(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
pathStyle: string = 'bezier',
): Waypoint {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts)
if (segments.length === 0) return pts[0]
const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0)
if (totalLength <= 0) return pts[Math.floor(pts.length / 2)]
let remaining = totalLength / 2
for (const segment of segments) {
if (remaining <= segment.length) {
const t = segment.length === 0 ? 0 : remaining / segment.length
return segment.pointAt(t)
}
remaining -= segment.length
}
const lastSegment = segments[segments.length - 1]
return lastSegment.pointAt(1)
}
function getBezierSegmentPoint(
pts: Waypoint[],
insertIndex: number,
t: number,
): Waypoint {
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
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 cp1 = {
x: p1.x + (p2.x - p0.x) / 6,
y: p1.y + (p2.y - p0.y) / 6,
}
const cp2 = {
x: p2.x - (p3.x - p1.x) / 6,
y: p2.y - (p3.y - p1.y) / 6,
}
return interpolateCubic(p1, cp1, cp2, p2, t)
}
export function getAddWaypointHandlePosition(
sourceX: number, sourceY: number,
waypoints: Waypoint[],
targetX: number, targetY: number,
insertIndex: number,
pathStyle: string = 'bezier',
): Waypoint {
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
if (pts.length < 2) return { x: sourceX, y: sourceY }
if (pathStyle !== 'smooth') {
return getBezierSegmentPoint(pts, insertIndex, 0.5)
}
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
return {
x: (pts[i].x + pts[i + 1].x) / 2,
y: (pts[i].y + pts[i + 1].y) / 2,
}
}
// ── 45° snapping ────────────────────────────────────────────────────────────── // ── 45° snapping ──────────────────────────────────────────────────────────────
/** /**
@@ -66,7 +66,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
minWidth={140} minWidth={140}
minHeight={50} minHeight={50}
lineStyle={{ borderColor: 'transparent' }} lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }} handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
/> />
<Handle <Handle
type="source" type="source"
@@ -76,6 +76,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
/> />
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Status dot — absolute to avoid affecting node auto-width */}
<div
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
{/* Main row */} {/* Main row */}
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden"> <div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
{/* Icon */} {/* Icon */}
@@ -121,7 +128,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
return ( return (
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}> <div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
{Icon && <Icon size={9} className="shrink-0" />} {Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span> <span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span> <span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div> </div>
) )
@@ -139,7 +146,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}> <div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
<Cpu size={9} className="shrink-0" /> <Cpu size={9} className="shrink-0" />
{data.cpu_model && ( {data.cpu_model && (
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span> <span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
)} )}
{data.cpu_count != null && ( {data.cpu_count != null && (
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span> <span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
@@ -166,13 +173,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</> </>
)} )}
{/* Status dot */}
<div
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => { {(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
const sourceId = BOTTOM_HANDLE_IDS[idx] const sourceId = BOTTOM_HANDLE_IDS[idx]
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t` const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
@@ -1,7 +1,12 @@
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 { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
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'
@@ -10,6 +15,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props const { data, selected } = props
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme] const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, activeTheme) const colors = resolveNodeColors(data, activeTheme)
@@ -41,6 +47,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 (
<> <>
@@ -49,7 +56,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
minHeight={160} minHeight={160}
isVisible={selected} isVisible={selected}
lineStyle={{ borderColor: glow, opacity: 0.6 }} lineStyle={{ borderColor: glow, opacity: 0.6 }}
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }} handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
/> />
{/* Group border */} {/* Group border */}
@@ -67,7 +74,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
> >
{/* Header bar */} {/* Header bar */}
<div <div
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0" className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
style={{ style={{
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`, background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`, borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
@@ -80,7 +87,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
@@ -89,23 +96,45 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
> >
{data.label} {data.label}
</span> </span>
{data.ip && ( {data.ip && splitIps(data.ip).map((ip) => (
<span <span
key={ip}
className="font-mono text-[9px] truncate" className="font-mono text-[9px] truncate"
style={{ color: theme.colors.nodeSubtextColor }} style={{ color: theme.colors.nodeSubtextColor }}
> >
{data.ip} {hideIp ? maskIp(ip) : ip}
</span> </span>
)} ))}
</div> </div>
{/* Status dot */} {/* Status dot */}
<div <div
className="w-1.5 h-1.5 rounded-full shrink-0" className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }} style={{ backgroundColor: statusColor }}
title={data.status} title={data.status}
/> />
</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-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
</div>
)
})}
{/* Inner area — React Flow places children here */} {/* Inner area — React Flow places children here */}
<div className="flex-1 relative" /> <div className="flex-1 relative" />
</div> </div>
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react' import { type NodeProps, type Node } from '@xyflow/react'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
} from 'lucide-react' } from 'lucide-react'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
@@ -22,5 +22,6 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} /> export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} /> export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} /> export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} /> export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} /> export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
@@ -1,4 +1,4 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index' import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, 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' import { GroupNode } from './GroupNode'
@@ -18,7 +18,8 @@ export const nodeTypes = {
printer: PrinterNode, printer: PrinterNode,
computer: ComputerNode, computer: ComputerNode,
cpl: CplNode, cpl: CplNode,
docker: DockerNode, docker_host: DockerHostNode,
docker_container: DockerContainerNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode, groupRect: GroupRectNode,
group: GroupNode, group: GroupNode,
@@ -0,0 +1,484 @@
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle,
type LucideIcon,
} from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEMES } from '@/utils/themes'
import { applyOpacity } from '@/utils/colorUtils'
import type {
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
} from '@/types'
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
const EDITABLE_NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
'docker_container', 'generic',
]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, switch: Network, server: Server, proxmox: Layers,
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
docker_host: Anchor, docker_container: Package, generic: Circle,
}
// ── Default style for a node type (from default theme) ─────────────────────
function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle {
const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic
return {
borderColor: accent.border,
borderOpacity: 1,
bgColor: THEMES.default.colors.nodeCardBackground,
bgOpacity: 1,
iconColor: accent.icon,
iconOpacity: 1,
width: 0,
height: 0,
}
}
function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
return {
color: THEMES.default.colors.edgeColors[edgeType],
opacity: 1,
pathStyle: 'bezier',
animated: 'none',
}
}
// ── Color + opacity row ──────────────────────────────────────────────────────
interface ColorRowProps {
label: string
color: string
opacity: number
onColorChange: (v: string) => void
onOpacityChange: (v: number) => void
}
function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) {
return (
<div className="flex items-center gap-3">
<span className="text-xs text-[#8b949e] w-20 shrink-0">{label}</span>
<input
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5"
/>
<div className="flex items-center gap-2 flex-1">
<input
type="range"
min={0}
max={1}
step={0.01}
value={opacity}
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
className="flex-1 h-1 accent-[#00d4ff]"
/>
<span className="text-xs text-[#8b949e] w-8 text-right">
{Math.round(opacity * 100)}%
</span>
</div>
<div
className="w-5 h-5 rounded border border-[#30363d] shrink-0"
style={{ background: applyOpacity(color, opacity) }}
/>
</div>
)
}
// ── Node type editor ─────────────────────────────────────────────────────────
interface NodeEditorProps {
nodeType: NodeType
style: NodeTypeStyle
onChange: (s: NodeTypeStyle) => void
onApplyToExisting: () => void
}
function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) {
const set = useCallback(<K extends keyof NodeTypeStyle>(k: K, v: NodeTypeStyle[K]) => {
onChange({ ...style, [k]: v })
}, [style, onChange])
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-semibold text-[#e6edf3]">{NODE_TYPE_LABELS[nodeType]}</div>
<div className="flex flex-col gap-3">
<ColorRow
label="Border"
color={style.borderColor}
opacity={style.borderOpacity}
onColorChange={(v) => set('borderColor', v)}
onOpacityChange={(v) => set('borderOpacity', v)}
/>
<ColorRow
label="Background"
color={style.bgColor}
opacity={style.bgOpacity}
onColorChange={(v) => set('bgColor', v)}
onOpacityChange={(v) => set('bgOpacity', v)}
/>
<ColorRow
label="Icon"
color={style.iconColor}
opacity={style.iconOpacity}
onColorChange={(v) => set('iconColor', v)}
onOpacityChange={(v) => set('iconOpacity', v)}
/>
</div>
<div className="border-t border-[#30363d] pt-3">
<div className="text-xs text-[#8b949e] mb-1">Default size</div>
<div className="text-xs text-[#8b949e]/60 mb-2">0 = auto (min 140 × 50 px, grows with content)</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-[#8b949e]">W</span>
<input
type="number"
min={0}
step={10}
value={style.width}
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-[#8b949e]">H</span>
<input
type="number"
min={0}
step={10}
value={style.height}
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
</div>
</div>
<Button
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onApplyToExisting}
>
Apply to existing {NODE_TYPE_LABELS[nodeType]} nodes
</Button>
</div>
)
}
// ── Edge type editor ─────────────────────────────────────────────────────────
interface EdgeEditorProps {
edgeType: EdgeType
style: EdgeTypeStyle
onChange: (s: EdgeTypeStyle) => void
onApplyToExisting: () => void
}
function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) {
const set = useCallback(<K extends keyof EdgeTypeStyle>(k: K, v: EdgeTypeStyle[K]) => {
onChange({ ...style, [k]: v })
}, [style, onChange])
return (
<div className="flex flex-col gap-4">
<div className="text-sm font-semibold text-[#e6edf3]">{EDGE_TYPE_LABELS[edgeType]}</div>
<div className="flex flex-col gap-3">
<ColorRow
label="Color"
color={style.color}
opacity={style.opacity}
onColorChange={(v) => set('color', v)}
onOpacityChange={(v) => set('opacity', v)}
/>
</div>
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
<div>
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
<div className="flex gap-2">
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => (
<button
key={ps}
type="button"
onClick={() => set('pathStyle', ps)}
className="px-3 py-1 text-xs rounded border transition-colors"
style={{
borderColor: style.pathStyle === ps ? '#00d4ff' : '#30363d',
background: style.pathStyle === ps ? '#00d4ff22' : 'transparent',
color: style.pathStyle === ps ? '#00d4ff' : '#8b949e',
}}
>
{ps.charAt(0).toUpperCase() + ps.slice(1)}
</button>
))}
</div>
</div>
<div>
<div className="text-xs text-[#8b949e] mb-2">Animation</div>
<select
value={style.animated}
onChange={(e) => set('animated', e.target.value as EdgeTypeStyle['animated'])}
className="w-full h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
>
<option value="none">None</option>
<option value="basic">Basic</option>
<option value="flow">Flow</option>
<option value="snake">Snake</option>
</select>
</div>
</div>
<Button
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onApplyToExisting}
>
Apply to existing {EDGE_TYPE_LABELS[edgeType]} edges
</Button>
</div>
)
}
// ── Main modal ───────────────────────────────────────────────────────────────
type Tab = 'nodes' | 'edges'
type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null
interface CustomStyleModalProps {
open: boolean
onClose: () => void
}
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
const { customStyle, setCustomStyle } = useThemeStore()
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
const [tab, setTab] = useState<Tab>('nodes')
const [selection, setSelection] = useState<Selection>(null)
const [draft, setDraft] = useState<CustomStyleDef>(() => ({
nodes: { ...customStyle.nodes },
edges: { ...customStyle.edges },
}))
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
// Reset draft to current saved customStyle on open
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
setSelection(null)
} else {
onClose()
}
}
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
draft.nodes[t] ?? defaultNodeStyle(t)
const getEdgeStyle = (t: EdgeType): EdgeTypeStyle =>
draft.edges[t] ?? defaultEdgeStyle(t)
const handleNodeChange = (t: NodeType, s: NodeTypeStyle) =>
setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } }))
const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) =>
setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } }))
const handleApplyNodeType = (t: NodeType) => {
const style = getNodeStyle(t)
applyTypeNodeStyle(t, style)
toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`)
}
const handleApplyEdgeType = (t: EdgeType) => {
const style = getEdgeStyle(t)
applyTypeEdgeStyle(t, style)
toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`)
}
const handleSave = () => {
setCustomStyle(draft)
markUnsaved()
toast.success('Custom style saved — save your canvas to persist')
onClose()
}
const handleApplyAll = () => {
setCustomStyle(draft)
applyAllCustomStyles(draft)
markUnsaved()
toast.success('Custom style applied to all nodes and edges')
onClose()
}
const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null
const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="bg-[#161b22] border-[#30363d] max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-5 pt-5 pb-3 border-b border-[#30363d]">
<DialogTitle className="text-sm font-semibold">Custom Style Editor</DialogTitle>
</DialogHeader>
<div className="flex flex-1 overflow-hidden min-h-0">
{/* Left panel — type list */}
<div className="w-52 shrink-0 border-r border-[#30363d] flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-[#30363d]">
{(['nodes', 'edges'] as Tab[]).map((t) => (
<button
key={t}
type="button"
onClick={() => { setTab(t); setSelection(null) }}
className="flex-1 py-2 text-xs font-medium transition-colors"
style={{
borderBottom: tab === t ? '2px solid #00d4ff' : '2px solid transparent',
color: tab === t ? '#00d4ff' : '#8b949e',
}}
>
{t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))}
</div>
{/* Type list */}
<div className="flex-1 overflow-y-auto py-1">
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
const Icon = NODE_ICONS[t] ?? Circle
const style = draft.nodes[t]
const isSelected = selection?.kind === 'node' && selection.type === t
const swatchColor = style
? applyOpacity(style.borderColor, style.borderOpacity)
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
return (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'node', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<Icon size={13} />
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
const style = draft.edges[t]
const isSelected = selection?.kind === 'edge' && selection.type === t
const swatchColor = style
? applyOpacity(style.color, style.opacity)
: THEMES.default.colors.edgeColors[t]
return (
<button
key={t}
type="button"
onClick={() => setSelection({ kind: 'edge', type: t })}
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
style={{
background: isSelected ? '#21262d' : 'transparent',
color: isSelected ? '#e6edf3' : '#8b949e',
}}
>
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
<span
className="w-8 h-1.5 rounded-full shrink-0"
style={{ background: swatchColor }}
/>
</button>
)
})}
</div>
</div>
{/* Right panel — editor */}
<div className="flex-1 overflow-y-auto p-5">
{!selection && (
<div className="flex items-center justify-center h-full text-xs text-[#8b949e]">
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
</div>
)}
{selection?.kind === 'node' && selectedNodeStyle && (
<NodeEditor
key={selection.type}
nodeType={selection.type}
style={selectedNodeStyle}
onChange={(s) => handleNodeChange(selection.type, s)}
onApplyToExisting={() => handleApplyNodeType(selection.type)}
/>
)}
{selection?.kind === 'edge' && selectedEdgeStyle && (
<EdgeEditor
key={selection.type}
edgeType={selection.type}
style={selectedEdgeStyle}
onChange={(s) => handleEdgeChange(selection.type, s)}
onApplyToExisting={() => handleApplyEdgeType(selection.type)}
/>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-between gap-2 px-5 py-3 border-t border-[#30363d]">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={onClose}
>
Cancel
</Button>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
className="border-[#30363d] text-[#e6edf3] hover:bg-[#21262d]"
onClick={handleSave}
>
Save Custom Style
</Button>
<Button
type="button"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={handleApplyAll}
>
Apply All to Canvas
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+19 -12
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import modalStyles from './modal-interactive.module.css'
import { RotateCcw } from 'lucide-react' import { RotateCcw } 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'
@@ -68,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Link Type</Label> <Label className="text-xs text-muted-foreground">Link Type</Label>
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}> <Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
<SelectValue /> <SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
{EDGE_TYPES.map(([value, label]) => ( {EDGE_TYPES.map(([value, label]) => (
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
value={vlanId} value={vlanId}
onChange={(e) => setVlanId(e.target.value)} onChange={(e) => setVlanId(e.target.value)}
placeholder="e.g. 20" placeholder="e.g. 20"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
)} )}
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..." placeholder="e.g. 1G, trunk..."
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path Style</Label> <Label className="text-xs text-muted-foreground">Path Style</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]"> <div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => ( {(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
<button <button
key={style} key={style}
type="button" type="button"
onClick={() => setPathStyle(style)} onClick={() => setPathStyle(style)}
className="flex-1 py-1 text-xs capitalize transition-colors" className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Path style ${style}`}
style={{ style={{
background: pathStyle === style ? '#00d4ff22' : '#21262d', background: pathStyle === style ? '#00d4ff22' : '#21262d',
color: pathStyle === style ? '#00d4ff' : '#8b949e', color: pathStyle === style ? '#00d4ff' : '#8b949e',
@@ -127,13 +130,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Animation</Label> <Label className="text-xs text-muted-foreground">Animation</Label>
<div className="flex rounded-md overflow-hidden border border-[#30363d]"> <div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => ( {(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
<button <button
key={mode} key={mode}
type="button" type="button"
onClick={() => setAnimation(mode)} onClick={() => setAnimation(mode)}
className="flex-1 py-1 text-xs capitalize transition-colors" className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
tabIndex={0}
aria-label={`Animation mode ${mode}`}
style={{ style={{
background: animation === mode ? '#00d4ff22' : '#21262d', background: animation === mode ? '#00d4ff22' : '#21262d',
color: animation === mode ? '#00d4ff' : '#8b949e', color: animation === mode ? '#00d4ff' : '#8b949e',
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
)} )}
</div> </div>
<label <label
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer" className={`relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }} style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
tabIndex={0}
aria-label="Edge color picker"
> >
<input <input
type="color" type="color"
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
<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 cursor-pointer" onClick={handleDelete}>
Delete Delete
</Button> </Button>
) : <span />} ) : <span />}
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button> <Button type="button" variant="ghost" size="sm" className="cursor-pointer" onClick={onClose}>Cancel</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 cursor-pointer">
{onDelete ? 'Save' : 'Connect'} {onDelete ? 'Save' : 'Connect'}
</Button> </Button>
</div> </div>
@@ -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>
)
}
@@ -1,10 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import modalStyles from './modal-interactive.module.css'
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, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types' import type { TextPosition } from '@/types'
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
@@ -128,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
value={form.label} value={form.label}
onChange={(e) => set('label', e.target.value)} onChange={(e) => set('label', e.target.value)}
placeholder="Zone name…" placeholder="Zone name…"
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
@@ -136,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Font</Label> <Label className="text-xs text-muted-foreground">Font</Label>
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}> <Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -161,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button" type="button"
title={value} title={value}
onClick={() => set('text_position', value)} onClick={() => set('text_position', value)}
className="h-8 rounded text-base transition-colors" className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -186,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value} key={value}
type="button" type="button"
onClick={() => set('label_position', value)} onClick={() => set('label_position', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors" className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -204,23 +206,35 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label> <Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => ( {colorFields.map(({ key, label }) => {
<div key={key} className="flex flex-col gap-1 items-center"> const { hex6, alpha } = hexToRgba(form[key])
<label return (
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden" <div key={key} className="flex flex-col gap-1 items-center">
style={{ borderColor: '#30363d' }} <label
> className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="color"
value={hex6}
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<input <input
type="color" type="range"
value={form[key]} min={0}
onChange={(e) => set(key, e.target.value)} max={100}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0" value={alpha}
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
title={`Opacity: ${alpha}%`}
/> />
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} /> <span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
</label> </div>
<span className="text-[9px] text-muted-foreground/60">{label}</span> )
</div> })}
))}
</div> </div>
</div> </div>
@@ -235,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value} key={value}
type="button" type="button"
onClick={() => set('text_size', value)} onClick={() => set('text_size', value)}
className="flex items-center justify-center h-8 rounded transition-colors" className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -262,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button" type="button"
title={label} title={label}
onClick={() => set('border_style', value)} onClick={() => set('border_style', value)}
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors" className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -288,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
key={value} key={value}
type="button" type="button"
onClick={() => set('border_width', value)} onClick={() => set('border_width', value)}
className="flex items-center justify-center h-8 rounded text-xs transition-colors" className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : '#21262d', background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`, border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
@@ -306,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label> <Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}> <Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
@@ -325,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
onClick={() => { onDelete(); onClose() }} onClick={() => { onDelete(); onClose() }}
> >
Delete Delete
</Button> </Button>
)} )}
<div className="flex gap-2 ml-auto"> <div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" size="sm" onClick={onClose}> <Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
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 cursor-pointer">
{title === 'Add Zone' ? 'Add' : 'Save'} {title === 'Add Zone' ? 'Add' : 'Save'}
</Button> </Button>
</div> </div>
+103 -57
View File
@@ -1,4 +1,5 @@
import { createElement, useState } from 'react' import { Fragment, createElement, useState } from 'react'
import modalStyles from './modal-interactive.module.css'
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'
@@ -11,12 +12,24 @@ import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] }, { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
] ]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
ping: 'Ping',
http: 'HTTP',
https: 'HTTPS',
tcp: 'TCP',
ssh: 'SSH',
prometheus: 'Prometheus',
health: 'Health',
}
const DEFAULT_DATA: Partial<NodeData> = { const DEFAULT_DATA: Partial<NodeData> = {
type: 'server', type: 'server',
@@ -26,7 +39,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
status: 'unknown', status: 'unknown',
check_method: 'ping', check_method: 'ping',
services: [], services: [],
container_mode: true, container_mode: false,
custom_colors: undefined, custom_colors: undefined,
custom_icon: undefined, custom_icon: undefined,
} }
@@ -37,14 +50,12 @@ 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)
@@ -60,10 +71,19 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return return
} }
setLabelError(false) setLabelError(false)
onSubmit(form) const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
onSubmit({
...form,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
onClose() onClose()
} }
const filteredParentNodes = form.type === 'docker_container'
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
: parentContainerNodes
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md"> <DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
@@ -77,14 +97,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<div className="flex flex-col gap-1.5"> <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 w-full"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
<SelectValue /> <SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPE_GROUPS.map((group, i) => ( {NODE_TYPE_GROUPS.map((group, i) => (
<> <Fragment key={group.label}>
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />} {i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectGroup key={group.label}> <SelectGroup>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1"> <SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label} {group.label}
</SelectLabel> </SelectLabel>
@@ -94,7 +114,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
</> </Fragment>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -118,7 +138,8 @@ 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 w-full" className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
aria-label="Icon picker trigger"
> >
<span className="flex items-center gap-2 min-w-0"> <span className="flex items-center gap-2 min-w-0">
{(() => { {(() => {
@@ -134,14 +155,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</button> </button>
</div> </div>
{/* Inline icon picker full width, shown below the type+icon row */} {/* Inline icon picker - full width, shown below the type+icon row */}
{iconPickerOpen && ( {iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2"> <div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
<Input <Input
value={iconSearch} value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)} onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…" placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7" className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
autoFocus autoFocus
/> />
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto"> <div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
@@ -163,7 +184,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
type="button" type="button"
title={entry.label} title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }} onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors" className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
aria-label={`Select icon ${entry.label}`}
style={{ style={{
background: isSelected ? '#00d4ff22' : 'transparent', background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent', border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
@@ -191,7 +213,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.label ?? ''} value={form.label ?? ''}
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }} onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server" placeholder="My Server"
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`} className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
/> />
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>} {labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div> </div>
@@ -203,31 +225,32 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.hostname ?? ''} value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)} onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan" placeholder="server.lan"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
{/* 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 <span className="text-muted-foreground/50">(comma-separated)</span></Label> <Label className="text-xs text-muted-foreground">IP Address</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, 2001:db8::1" 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 ${modalStyles['modal-radius']}`}
/> />
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
</div> </div>
{/* Check method */} {/* Check method */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<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 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
<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>
@@ -240,24 +263,28 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.check_target ?? ''} value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)} onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..." placeholder="http://..."
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8" className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</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 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
<SelectValue placeholder="None (standalone)" /> <SelectValue placeholder="None (standalone)">
{form.parent_id
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
: 'None (standalone)'}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem> <SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{proxmoxNodes.map((n) => ( {filteredParentNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem> <SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -265,24 +292,26 @@ 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 ${modalStyles['modal-interactive']}`}
tabIndex={0}
aria-label="Toggle container mode"
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,9 +339,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return ( return (
<div key={key} className="flex flex-col gap-1 items-center"> <div key={key} className="flex flex-col gap-1 items-center">
<label <label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all" className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
style={{ borderColor: isCustom ? currentValue : '#30363d' }} style={{ borderColor: isCustom ? currentValue : '#30363d' }}
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`} title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
tabIndex={0}
aria-label={`Color picker for ${key}`}
> >
<input <input
type="color" type="color"
@@ -340,14 +371,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={String(form.bottom_handles ?? 1)} value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))} onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
> >
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8"> <SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Bottom connection points selector">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]"> <SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 center</SelectItem> <SelectItem value="1" className="text-sm">1 - center</SelectItem>
<SelectItem value="2" className="text-sm">2 left / right</SelectItem> <SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 left / center / right</SelectItem> <SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 evenly spaced</SelectItem> <SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -360,22 +391,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
value={form.notes ?? ''} value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)} onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes" placeholder="Optional notes"
className="bg-[#21262d] border-[#30363d] text-sm h-8" className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
/> />
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-between gap-2 pt-1">
<Button type="button" variant="ghost" size="sm" onClick={onClose}> {/* Show delete button only for edit mode (not add) */}
Cancel {title !== 'Add Node' ? (
</Button> <Button
<Button type="button"
type="submit" variant="ghost"
size="sm" size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
> onClick={() => { if (window.confirm('Delete this node?')) onSubmit({ ...form, _delete: true }); onClose(); }}
{title === 'Add Node' ? 'Add' : 'Save'} style={{ minWidth: 64 }}
</Button> >
Delete
</Button>
) : <span />}
<div className="flex gap-2">
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
Cancel
</Button>
<Button
type="submit"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer"
>
{title === 'Add Node' ? 'Add' : 'Save'}
</Button>
</div>
</div> </div>
</form> </form>
</DialogContent> </DialogContent>
+172 -107
View File
@@ -1,11 +1,12 @@
import { useState } from 'react' import { useRef, useState, type KeyboardEvent } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Check } from 'lucide-react' import { Check, Pencil } 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 { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes' import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { CustomStyleModal } from './CustomStyleModal'
// Node-type accent colors to display as preview swatches // Node-type accent colors to display as preview swatches
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
@@ -14,74 +15,108 @@ interface ThemeCardProps {
themeId: ThemeId themeId: ThemeId
selected: boolean selected: boolean
onClick: () => void onClick: () => void
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
buttonRef?: (element: HTMLButtonElement | null) => void
onEdit?: () => void
} }
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) { function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) {
const { customStyle } = useThemeStore()
const preset = THEMES[themeId] const preset = THEMES[themeId]
const c = preset.colors const c = preset.colors
const isCustom = themeId === 'custom'
// For custom theme, use defined node colors for preview swatches
const swatchColors = isCustom
? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border)
: PREVIEW_TYPES.map((t) => c.nodeAccents[t].border)
const ethernetColor = isCustom
? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet)
: c.edgeColors.ethernet
const wifiColor = isCustom
? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi)
: c.edgeColors.wifi
return ( return (
<button <div className="relative w-full h-full">
type="button" <button
onClick={onClick} ref={buttonRef}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full" type="button"
style={{ onClick={onClick}
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground, onKeyDown={onKeyDown}
background: c.canvasBackground, className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none', style={{
}} borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
> background: c.canvasBackground,
{/* Selected checkmark */} boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
{selected && ( }}
<span
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
style={{ background: c.nodeAccents.isp.border }}
>
<Check size={10} style={{ color: c.canvasBackground }} />
</span>
)}
{/* Mini canvas preview */}
<div
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
> >
{/* Node accent dots */} {/* Selected checkmark */}
<div className="flex gap-1 items-center flex-wrap"> {selected && (
{PREVIEW_TYPES.map((type) => ( <span
<span className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
key={type} style={{ background: c.nodeAccents.isp.border }}
className="w-2.5 h-2.5 rounded-full shrink-0" >
style={{ backgroundColor: c.nodeAccents[type].border }} <Check size={10} style={{ color: c.canvasBackground }} />
/> </span>
))} )}
</div>
{/* Edge line */} {/* Mini canvas preview */}
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
{/* Wifi dashed line */}
<div <div
style={{ className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
height: 1, style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
width: '55%', >
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`, <div className="flex gap-1 items-center flex-wrap">
}} {swatchColors.map((color, i) => (
/> <span
</div> key={i}
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
))}
</div>
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
}}
/>
</div>
{/* Label */} <div
<div className="text-sm font-semibold leading-tight wrap-break-word"
className="text-xs font-semibold leading-tight" style={{ color: c.nodeLabelColor }}
style={{ color: c.nodeLabelColor }} >
> {preset.label}
{preset.label} </div>
</div> <div
<div className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
className="text-[10px] leading-snug mt-0.5 line-clamp-2" style={{ color: c.nodeSubtextColor }}
style={{ color: c.nodeSubtextColor }} >
> {preset.description}
{preset.description} </div>
</div> </button>
</button>
{/* Edit button — only for custom theme */}
{isCustom && onEdit && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onEdit() }}
title="Edit custom style"
className="absolute bottom-2 right-2 flex items-center justify-center w-6 h-6 rounded-md transition-colors"
style={{
background: c.nodeCardBackground,
color: c.nodeLabelColor,
border: `1px solid ${c.handleBackground}`,
}}
>
<Pencil size={11} />
</button>
)}
</div>
) )
} }
@@ -93,6 +128,8 @@ interface ThemeModalProps {
export function ThemeModal({ open, onClose }: ThemeModalProps) { export function ThemeModal({ open, onClose }: ThemeModalProps) {
const { activeTheme, setTheme } = useThemeStore() const { activeTheme, setTheme } = useThemeStore()
const { markUnsaved } = useCanvasStore() const { markUnsaved } = useCanvasStore()
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
const [customStyleOpen, setCustomStyleOpen] = useState(false)
// Capture the theme that was active when the modal opened // Capture the theme that was active when the modal opened
const [originalTheme] = useState<ThemeId>(activeTheme) const [originalTheme] = useState<ThemeId>(activeTheme)
@@ -100,68 +137,96 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
const handleSelect = (id: ThemeId) => { const handleSelect = (id: ThemeId) => {
setSelected(id) setSelected(id)
// Live-preview the selected theme on the canvas
setTheme(id) setTheme(id)
} }
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
handleApply()
return
}
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
event.preventDefault()
const direction = event.key === 'ArrowRight' ? 1 : -1
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
const nextTheme = THEME_ORDER[nextIndex]
setSelected(nextTheme)
setTheme(nextTheme)
const nextCard = cardRefs.current[nextIndex]
if (!nextCard) return
nextCard.focus({ preventScroll: true })
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
}
const handleApply = () => { const handleApply = () => {
setTheme(selected) setTheme(selected)
markUnsaved() markUnsaved()
onClose() onClose()
toast.info('Style applied — save your canvas to make it permanent', { toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
duration: 5000,
})
} }
const handleCancel = () => { const handleCancel = () => {
// Revert to the original theme
setTheme(originalTheme) setTheme(originalTheme)
onClose() onClose()
} }
return ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}> <>
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl"> <Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
<DialogHeader> <DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle> <DialogHeader>
</DialogHeader> <DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-5 gap-3 py-1"> <div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
{THEME_ORDER.map((id) => ( {THEME_ORDER.map((id, index) => (
<ThemeCard <div key={id} className="shrink-0 w-30 md:w-24 h-full">
key={id} <ThemeCard
themeId={id} themeId={id}
selected={selected === id} selected={selected === id}
onClick={() => handleSelect(id)} onClick={() => handleSelect(id)}
/> onKeyDown={handleCardKeyDown(index)}
))} buttonRef={(element) => { cardRefs.current[index] = element }}
</div> onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
/>
</div>
))}
</div>
<div className="flex justify-end gap-2 pt-1"> <div className="flex justify-end gap-2 pt-1">
<Button <Button
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
onClick={handleCancel} onClick={handleCancel}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
style={ style={
selected !== 'default' selected !== 'default'
? { background: THEMES[selected].colors.nodeAccents.isp.border } ? { background: THEMES[selected].colors.nodeAccents.isp.border }
: undefined : undefined
} }
onClick={handleApply} onClick={handleApply}
> >
Apply Style Apply Style
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
</>
) )
} }
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ExportModal } from '../ExportModal'
const mockExportToPng = vi.fn()
vi.mock('@/utils/export', () => ({
exportToPng: (...args: unknown[]) => mockExportToPng(...args),
EXPORT_QUALITY_OPTIONS: [
{ value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
{ value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
],
}))
const el = document.createElement('div')
const getElement = () => el
const onClose = vi.fn()
describe('ExportModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockExportToPng.mockResolvedValue(undefined)
})
it('renders all three quality options', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
expect(screen.getByText('Standard')).toBeInTheDocument()
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.getByText('Ultra')).toBeInTheDocument()
})
it('selects High by default', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
const highBtn = screen.getByText('High').closest('button')!
expect(highBtn.className).toContain('border-[#00d4ff]')
})
it('changes selection when another option is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Ultra').closest('button')!)
expect(screen.getByText('Ultra').closest('button')!.className).toContain('border-[#00d4ff]')
expect(screen.getByText('High').closest('button')!.className).not.toContain('border-[#00d4ff]')
})
it('calls exportToPng with selected quality on Download click', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByText('Standard').closest('button')!)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).toHaveBeenCalledWith(el, 'standard'))
})
it('closes after successful export', async () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(onClose).toHaveBeenCalled())
})
it('calls onClose when Cancel is clicked', () => {
render(<ExportModal open onClose={onClose} getElement={getElement} />)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onClose).toHaveBeenCalled()
})
it('does not call exportToPng when getElement returns null', async () => {
render(<ExportModal open onClose={onClose} getElement={() => null} />)
fireEvent.click(screen.getByRole('button', { name: /download/i }))
await waitFor(() => expect(mockExportToPng).not.toHaveBeenCalled())
})
it('does not render when closed', () => {
render(<ExportModal open={false} onClose={onClose} getElement={getElement} />)
expect(screen.queryByText('Export as PNG')).not.toBeInTheDocument()
})
})
@@ -251,4 +251,60 @@ describe('GroupRectModal', () => {
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_style).toBe('solid') expect(submitted.border_style).toBe('solid')
}) })
it('shows opacity sliders for all three color fields', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
const sliders = screen.getAllByRole('slider')
expect(sliders).toHaveLength(3)
})
it('default background_color is 8-digit hex with low alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.background_color).toBe('#00d4ff0d')
expect(submitted.background_color.length).toBe(9)
})
it('moving background opacity slider updates background_color alpha', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// background slider is the third one (Text, Border, Background)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[2], { target: { value: '50' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
// alpha 50% → 0x80 = 128
expect(submitted.background_color).toBe('#00d4ff80')
})
it('moving border opacity slider to 0 makes border fully transparent', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const sliders = screen.getAllByRole('slider')
fireEvent.change(sliders[1], { target: { value: '0' } })
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.border_color).toBe('#00d4ff00')
})
it('pre-fills opacity from 8-digit initial background_color', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ background_color: '#ff6e0080' }}
/>
)
const sliders = screen.getAllByRole('slider')
expect((sliders[2] as HTMLInputElement).value).toBe('50')
})
it('shows opacity percentage in label', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
// Background default is 5% opacity
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
})
}) })
@@ -130,6 +130,21 @@ describe('NodeModal', () => {
expect(data.notes).toBe('rack A') 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', () => { it('submits check_target', () => {
const { onSubmit } = renderModal({ initial: BASE }) const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } }) fireEvent.change(screen.getByPlaceholderText('http://...'), { target: { value: 'http://192.168.1.10:8080' } })
@@ -219,15 +234,18 @@ describe('NodeModal', () => {
expect(screen.queryByTitle('Router')).toBeNull() expect(screen.queryByTitle('Router')).toBeNull()
}) })
// ── Container mode (proxmox only) ───────────────────────────────────── // ── Container mode ─────────────────────────────────────────────────────
it('shows Container Mode toggle for proxmox type', () => { const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
renderModal({ initial: { ...BASE, type: 'proxmox' } }) 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() expect(screen.getByText('Container Mode')).toBeDefined()
}) })
it('hides Container Mode for non-proxmox types', () => { it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
renderModal({ initial: BASE }) renderModal({ initial: { ...BASE, type } })
expect(screen.queryByText('Container Mode')).toBeNull() expect(screen.queryByText('Container Mode')).toBeNull()
}) })
@@ -238,33 +256,48 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
}) })
// ── Parent Proxmox (vm / lxc only) ─────────────────────────────────── // ── Parent container ──────────────────────────────────────────────────
it('shows Parent Proxmox for vm with proxmoxNodes', () => { 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({ renderModal({
initial: { ...BASE, type: 'vm' }, initial: { ...BASE, type },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }], parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
}) })
expect(screen.getByText('Parent Proxmox')).toBeDefined() expect(screen.getByText('Parent Container')).toBeDefined()
expect(screen.getByText('PVE-01')).toBeDefined() expect(screen.getByText('Container 01')).toBeDefined()
}) })
it('shows Parent Proxmox for lxc with proxmoxNodes', () => { 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({ renderModal({
initial: { ...BASE, type: 'lxc' }, initial: { ...BASE, type: 'docker_container' },
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }], parentContainerNodes: [
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
],
}) })
expect(screen.getByText('Parent Proxmox')).toBeDefined() expect(screen.getByText('My Docker Host')).toBeDefined()
expect(screen.queryByText('My Proxmox')).toBeNull()
}) })
it('hides Parent Proxmox for server type', () => { it('docker_container hides Parent Container when no docker_host is available', () => {
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] }) renderModal({
expect(screen.queryByText('Parent Proxmox')).toBeNull() initial: { ...BASE, type: 'docker_container' },
}) parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
})
it('hides Parent Proxmox for vm when no proxmoxNodes', () => { expect(screen.queryByText('Parent Container')).toBeNull()
renderModal({ initial: { ...BASE, type: 'vm' } })
expect(screen.queryByText('Parent Proxmox')).toBeNull()
}) })
// ── Appearance ──────────────────────────────────────────────────────── // ── Appearance ────────────────────────────────────────────────────────
@@ -0,0 +1,27 @@
/* SidebarItem pointer on hover */
.sidebar-pointer:hover {
cursor: pointer !important;
}
/* Consistent border radius for all modal input/select/button elements */
.modal-radius {
border-radius: 6px !important;
}
/* Pointer cursor for close X */
.modal-close-pointer:hover {
cursor: pointer !important;
}
/* Subtle hover background for cancel button */
.modal-cancel-hover:hover {
background: #21262d !important;
}
/* Shared hover/focus border effect for interactive modal elements */
.modal-interactive {
transition: border-color 0.15s;
}
.modal-interactive:hover,
.modal-interactive:focus {
border-color: #8b949e !important;
outline: none;
}
+141 -31
View File
@@ -2,6 +2,7 @@ import { createElement, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
@@ -13,8 +14,8 @@ interface DetailPanelProps {
onEdit: (id: string) => void onEdit: (id: string) => void
} }
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string } type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' } const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '', path: '' }
type PropForm = { key: string; value: string; icon: string | null; visible: boolean } type PropForm = { key: string; value: string; icon: string | null; visible: boolean }
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
@@ -94,10 +95,18 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
} }
const handleAddService = () => { const handleAddService = () => {
const port = parseInt(newSvc.port, 10) const trimmedPort = newSvc.port.trim()
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!newSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory() snapshotHistory()
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() } const path = newSvc.path.trim()
const svc: ServiceInfo = {
...(port != null ? { port } : {}),
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
...(path ? { path } : {}),
}
updateNode(node.id, { services: [...services, svc] }) updateNode(node.id, { services: [...services, svc] })
setNewSvc(EMPTY_FORM) setNewSvc(EMPTY_FORM)
setAddingForNode(null) setAddingForNode(null)
@@ -113,18 +122,29 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
const handleStartEdit = (index: number) => { const handleStartEdit = (index: number) => {
const svc = services[index] const svc = services[index]
if (!svc) return if (!svc) return
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name }) setEditSvc({ port: svc.port != null ? String(svc.port) : '', protocol: svc.protocol, service_name: svc.service_name, path: svc.path ?? '' })
setEditingFor({ nodeId: node.id, index }) setEditingFor({ nodeId: node.id, index })
setAddingForNode(null) setAddingForNode(null)
} }
const handleSaveEdit = () => { const handleSaveEdit = () => {
if (editingIndex === null) return if (editingIndex === null) return
const port = parseInt(editSvc.port, 10) const trimmedPort = editSvc.port.trim()
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return const port = trimmedPort === '' ? undefined : parseInt(trimmedPort, 10)
if (!editSvc.service_name.trim()) return
if (trimmedPort !== '' && (port == null || Number.isNaN(port) || port < 1 || port > 65535)) return
snapshotHistory() snapshotHistory()
const path = editSvc.path.trim()
const updated = services.map((svc, i) => const updated = services.map((svc, i) =>
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc i === editingIndex
? {
...svc,
protocol: editSvc.protocol,
service_name: editSvc.service_name.trim(),
...(port != null ? { port } : { port: undefined }),
...(path ? { path } : { path: undefined }),
}
: svc
) )
updateNode(node.id, { services: updated }) updateNode(node.id, { services: updated })
setEditingFor(null) setEditingFor(null)
@@ -180,7 +200,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span> <span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors"> <button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>
@@ -223,7 +243,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span> <span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
<button <button
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }} onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors" className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
> >
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
@@ -269,7 +289,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
<div className="px-4 py-3 border-t border-border"> <div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span> <span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"> <button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
<Plus size={10} /> Add <Plus size={10} /> Add
</button> </button>
</div> </div>
@@ -280,7 +300,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
editingIndex === i ? ( editingIndex === i ? (
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus /> <ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
) : ( ) : (
<ServiceBadge key={`${svc.port}-${svc.protocol}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} /> <ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
) )
)} )}
</div> </div>
@@ -296,10 +316,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
)} )}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border"> <div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}> <Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
<Edit size={14} /> Edit <Edit size={14} /> Edit
</Button> </Button>
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}> <Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
<Trash2 size={14} /> <Trash2 size={14} />
</Button> </Button>
</div> </div>
@@ -480,23 +500,46 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
} }
function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: { function ServiceForm({ form, onChange, onConfirm, onCancel, confirmLabel, autoFocus }: {
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string } form: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string; path: string }) => void
onConfirm: () => void onConfirm: () => void
onCancel: () => void onCancel: () => void
confirmLabel: string confirmLabel: string
autoFocus?: boolean autoFocus?: boolean
}) { }) {
const setPort = (value: string) => {
const digitsOnly = value.replace(/\D/g, '').slice(0, 5)
onChange({ ...form, port: digitsOnly })
}
const clampPort = (value: string) => {
if (!value) return ''
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) return ''
return String(Math.max(1, Math.min(65535, parsed)))
}
return ( return (
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]"> <div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> <Input value={form.service_name} onChange={(e) => onChange({ ...form, service_name: e.target.value })} placeholder="Service name" className="bg-[#21262d] border-[#30363d] text-xs h-7" autoFocus={autoFocus} onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Input type="number" value={form.port} onChange={(e) => onChange({ ...form, port: e.target.value })} placeholder="Port" min={1} max={65535} className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} /> <Input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={form.port}
onChange={(e) => setPort(e.target.value)}
onBlur={() => onChange({ ...form, port: clampPort(form.port) })}
placeholder="Port"
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-28 shrink-0"
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
/>
<select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"> <select value={form.protocol} onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })} className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground">
<option value="tcp">tcp</option> <option value="tcp">tcp</option>
<option value="udp">udp</option> <option value="udp">udp</option>
</select> </select>
</div> </div>
<Input value={form.path} onChange={(e) => onChange({ ...form, path: e.target.value })} placeholder="Path (/admin)" className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7" onKeyDown={(e) => e.key === 'Enter' && onConfirm()} />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button> <Button size="sm" className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm}>{confirmLabel}</Button>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button> <Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>Cancel</Button>
@@ -619,20 +662,87 @@ const CATEGORY_COLORS: Record<string, string> = {
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) { function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
const url = getServiceUrl(svc, host) const url = getServiceUrl(svc, host)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const inner = ( const hasPort = svc.port != null
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}> const portLabel = hasPort ? String(svc.port) : ''
<div className="flex items-center gap-1.5 min-w-0"> const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span> return (
</div> <div
<div className="flex items-center gap-1.5 shrink-0"> className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span> style={{ background: '#21262d', borderColor: '#30363d' }}
{url && <ExternalLink size={10} className="text-muted-foreground" />} >
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button> <span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button> {url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="font-medium truncate min-w-0 flex-1"
style={{ color }}
title={svc.service_name}
onClick={e => e.stopPropagation()}
>
{svc.service_name}
</a>
) : (
<span
className="font-medium truncate min-w-0 flex-1"
style={{ color }}
title={svc.service_name}
>
{svc.service_name}
</span>
)}
<div className="flex items-center gap-1 shrink-0">
{pathLabel && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className="truncate text-[#8b949e] max-w-[80px]"
tabIndex={0}
aria-label={pathLabel}
>
{pathLabel}
</span>
</TooltipTrigger>
<TooltipContent side="top">{pathLabel}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{hasPort && (
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
)}
{url ? (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
aria-label="Open service link"
style={{ color: 'inherit' }}
onClick={e => e.stopPropagation()}
>
<ExternalLink size={10} className="text-muted-foreground" />
</a>
) : (
<span className="w-2.5 shrink-0" />
)}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
title="Edit service"
>
<Pencil size={10} />
</button>
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
title="Remove service"
>
<X size={10} />
</button>
</div> </div>
</div> </div>
) )
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
return inner
} }
+19 -8
View File
@@ -1,11 +1,13 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client' import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease' import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -43,6 +45,7 @@ interface SidebarProps {
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false) const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas') const [_activeView, setActiveView] = useState<SidebarView>('canvas')
const logout = useAuthStore((s) => s.logout)
// When forceView is set, override local state without useEffect // When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed const collapsed = forceView ? false : _collapsed
@@ -152,6 +155,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')} onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/> />
)} )}
{!STANDALONE && (
<SidebarItem
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
/>
)}
</div> </div>
{!collapsed && <VersionBadge />} {!collapsed && <VersionBadge />}
@@ -359,7 +370,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p> <p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)} )}
{devices.map((d) => { {devices.map((d) => {
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port)) const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
const titleService = namedService const titleService = namedService
?? d.services.find((s) => s.port === 80) ?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443) ?? d.services.find((s) => s.port === 443)
@@ -385,8 +396,8 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
<input <input
type="checkbox" type="checkbox"
checked={checkedIds.has(d.id)} checked={checkedIds.has(d.id)}
onClick={(e) => toggleCheck(d.id, e)} onClick={(e) => e.stopPropagation()}
onChange={() => {}} onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0" className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
/> />
<span className="text-foreground truncate font-medium">{title}</span> <span className="text-foreground truncate font-medium">{title}</span>
@@ -583,7 +594,7 @@ function ScanHistoryPanel() {
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div> <div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
)} )}
{r.error && ( {r.error && (
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap"> <div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
{r.error} {r.error}
</div> </div>
)} )}
@@ -627,7 +638,7 @@ function SettingsPanel() {
min={10} min={10}
max={3600} max={3600}
value={interval} value={interval}
onChange={(e) => setIntervalValue(Number(e.target.value))} onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]" className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
/> />
<span className="text-xs text-muted-foreground">seconds</span> <span className="text-xs text-muted-foreground">seconds</span>
@@ -664,7 +675,7 @@ function VersionBadge() {
</a> </a>
{hasUpdate && latest && ( {hasUpdate && latest && (
<a <a
href={latest.url} href={latest.url.startsWith('https://') ? latest.url : '#'}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start" className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
@@ -739,7 +750,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
const btn = ( const btn = (
<button <button
onClick={onClick} onClick={onClick}
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${ className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
active active
? 'bg-[#00d4ff]/10 text-[#00d4ff]' ? 'bg-[#00d4ff]/10 text-[#00d4ff]'
: accent : accent
+10 -10
View File
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<div className="flex-1" /> <div className="flex-1" />
<Button <Button
size="sm" variant="ghost" size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30" className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onUndo} onClick={onUndo}
disabled={past.length === 0} disabled={past.length === 0}
title="Undo (Ctrl+Z)" title="Undo (Ctrl+Z)"
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
</Button> </Button>
<Button <Button
size="sm" variant="ghost" size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30" className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
onClick={onRedo} onClick={onRedo}
disabled={future.length === 0} disabled={future.length === 0}
title="Redo (Ctrl+Y)" title="Redo (Ctrl+Y)"
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
<Redo2 size={14} /> <Redo2 size={14} />
</Button> </Button>
<div className="w-px h-4 bg-border mx-1" /> <div className="w-px h-4 bg-border mx-1" />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout <LayoutDashboard size={14} /> Auto Layout
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
<Palette size={14} /> Style <Palette size={14} /> Style
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
<Upload size={14} /> Import <Upload size={14} /> Import
</Button> </Button>
<input <input
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
className="hidden" className="hidden"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
<Download size={14} /> Export <Download size={14} /> Export
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
<FileDown size={14} /> PNG <FileDown size={14} /> PNG
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
<Table2 size={14} /> MD <Table2 size={14} /> MD
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)"> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
<HelpCircle size={14} /> <HelpCircle size={14} />
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="gap-1.5 relative" className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
style={{ style={{
background: hasUnsavedChanges ? '#00d4ff' : undefined, background: hasUnsavedChanges ? '#00d4ff' : undefined,
color: hasUnsavedChanges ? '#0d1117' : undefined, color: hasUnsavedChanges ? '#0d1117' : undefined,
@@ -2,10 +2,11 @@ import { describe, it, expect } from 'vitest'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
import type { ServiceInfo } from '@/types' import type { ServiceInfo } from '@/types'
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({ const svc = (port?: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test', path?: string): ServiceInfo => ({
port, ...(port != null ? { port } : {}),
protocol, protocol,
service_name, service_name,
...(path ? { path } : {}),
}) })
describe('getServiceUrl', () => { describe('getServiceUrl', () => {
@@ -63,4 +64,20 @@ describe('getServiceUrl', () => {
it('uses host string directly (works with both IP and hostname)', () => { it('uses host string directly (works with both IP and hostname)', () => {
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80') expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
}) })
it('uses the node port when the host already includes one', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:8080')
})
it('lets the service port override the node port', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app'), '192.168.1.10:8080')).toBe('http://192.168.1.10:3000')
})
it('appends a normalized path to the final URL', () => {
expect(getServiceUrl(svc(3000, 'tcp', 'app', 'admin/login'), '192.168.1.10')).toBe('http://192.168.1.10:3000/admin/login')
})
it('supports path-only services inheriting the node port', () => {
expect(getServiceUrl(svc(undefined, 'tcp', 'app', '/metrics'), '192.168.1.10:9090')).toBe('http://192.168.1.10:9090/metrics')
})
}) })
@@ -293,9 +293,35 @@ describe('DetailPanel', () => {
fireEvent.click(addHeaders[addHeaders.length - 1]) fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } }) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' }) fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce() expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' }) expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' })
})
it('allows adding a service without a port', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10:8080' })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' })
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
}) })
it('calls updateNode without the removed service when X is clicked', () => { it('calls updateNode without the removed service when X is clicked', () => {
@@ -332,7 +358,7 @@ describe('DetailPanel', () => {
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
it('shows edit form pre-filled when pencil is clicked', () => { it('shows edit form pre-filled when pencil is clicked', () => {
setupStore({ services: [svc] }) setupStore({ services: [{ ...svc, path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />) render(<DetailPanel onEdit={vi.fn()} />)
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only) // Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
const editBtn = screen.getByTitle('Edit service') const editBtn = screen.getByTitle('Edit service')
@@ -341,6 +367,8 @@ describe('DetailPanel', () => {
expect(nameInput.value).toBe('nginx') expect(nameInput.value).toBe('nginx')
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
expect(portInput.value).toBe('80') expect(portInput.value).toBe('80')
const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement
expect(pathInput.value).toBe('/admin')
}) })
it('calls updateNode with updated values on Save', () => { it('calls updateNode with updated values on Save', () => {
@@ -359,11 +387,13 @@ describe('DetailPanel', () => {
const nameInput = screen.getByPlaceholderText('Service name') const nameInput = screen.getByPlaceholderText('Service name')
fireEvent.change(nameInput, { target: { value: 'apache' } }) fireEvent.change(nameInput, { target: { value: 'apache' } })
fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' })) fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updateNode).toHaveBeenCalledOnce() expect(updateNode).toHaveBeenCalledOnce()
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache') expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80) expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin')
}) })
it('cancels edit without updating', () => { it('cancels edit without updating', () => {
@@ -415,4 +445,46 @@ describe('DetailPanel', () => {
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined() expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
}) })
}) })
describe('ServiceBadge rendering', () => {
it('renders service name and port/protocol label', () => {
setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('nginx')).toBeDefined()
expect(screen.getByText('8080/tcp')).toBeDefined()
})
it('renders path label when path is set', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('/admin')).toBeDefined()
})
it('renders no path text when path is empty', () => {
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByText('/')).toBeNull()
})
it('renders port/protocol omitted when port is absent', () => {
setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health')).toBeDefined()
expect(screen.queryByText(/\/tcp/)).toBeNull()
})
it('renders service name as link when ip and port are set', () => {
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
const link = screen.getByRole('link', { name: 'nginx' })
expect(link.getAttribute('href')).toContain('192.168.1.10')
expect(link.getAttribute('target')).toBe('_blank')
})
it('renders service name as plain text when no url can be built', () => {
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByText('health').tagName).not.toBe('A')
})
})
}) })
@@ -2,12 +2,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
// ── Mocks ──────────────────────────────────────────────────────────────────── // ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore') vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockBulkApprove = vi.fn() const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn() const mockBulkHide = vi.fn()
@@ -60,6 +62,7 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
}) })
const mockToggleHideIp = vi.fn() const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) { function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
@@ -73,6 +76,12 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
} as ReturnType<typeof useCanvasStore>) } as ReturnType<typeof useCanvasStore>)
} }
function mockAuth() {
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
)
}
const defaultProps = { const defaultProps = {
onAddNode: vi.fn(), onAddNode: vi.fn(),
onAddGroupRect: vi.fn(), onAddGroupRect: vi.fn(),
@@ -86,6 +95,7 @@ const defaultProps = {
describe('Sidebar', () => { describe('Sidebar', () => {
beforeEach(() => { beforeEach(() => {
mockStore() mockStore()
mockAuth()
vi.clearAllMocks() vi.clearAllMocks()
}) })
@@ -267,6 +277,19 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Settings' })) fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
}) })
// ── Logout ─────────────────────────────────────────────────────────────────
it('shows Logout button in normal mode', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
})
}) })
// ── PendingDevicesPanel — bulk select ───────────────────────────────────────── // ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
@@ -298,6 +321,7 @@ const DEVICE_B = {
describe('PendingDevicesPanel — bulk select', () => { describe('PendingDevicesPanel — bulk select', () => {
beforeEach(() => { beforeEach(() => {
mockStore() mockStore()
mockAuth()
vi.clearAllMocks() vi.clearAllMocks()
mockBulkApprove.mockResolvedValue({ mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 }, data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
+3 -3
View File
@@ -1,3 +1,4 @@
import modalStyles from '../modals/modal-interactive.module.css'
import * as React from "react" import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
@@ -63,13 +64,12 @@ function DialogContent({
render={ render={
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-2 right-2" className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
size="icon-sm" size="icon-sm"
/> />
} }
> >
<XIcon <XIcon />
/>
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
+16 -50
View File
@@ -1,64 +1,30 @@
"use client" import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
import { cn } from "@/lib/utils" const TooltipTrigger = TooltipPrimitive.Trigger
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({ function TooltipContent({
className, className,
side = "top",
sideOffset = 4, sideOffset = 4,
align = "center",
alignOffset = 0,
children, children,
...props ...props
}: TooltipPrimitive.Popup.Props & }: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner <TooltipPrimitive.Content
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
className="isolate z-50" className={[
'z-50 overflow-hidden rounded-md border border-[#30363d] bg-[#161b22] px-2 py-1 text-xs text-[#e6edf3] shadow-md',
'animate-in fade-in-0 zoom-in-95',
className,
]
.filter(Boolean)
.join(' ')}
{...props}
> >
<TooltipPrimitive.Popup {children}
data-slot="tooltip-content" </TooltipPrimitive.Content>
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )
} }
@@ -49,6 +49,32 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true) expect(hasUnsavedChanges).toBe(true)
}) })
it('addNode nests under parent only when parent is in container mode', () => {
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
const child = { ...makeNode('c1', { parent_id: 'p1' }), position: { x: 150, y: 180 } }
useCanvasStore.getState().addNode(parent)
useCanvasStore.getState().addNode(child)
const childNode = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(childNode?.parentId).toBeUndefined()
useCanvasStore.getState().updateNode('p1', { container_mode: true })
useCanvasStore.getState().setProxmoxContainerMode('p1', true)
const nested = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
expect(nested?.parentId).toBe('p1')
expect(nested?.extent).toBe('parent')
})
it('docker_container nests under docker_host with container_mode on', () => {
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(host)
useCanvasStore.getState().addNode(container)
useCanvasStore.getState().updateNode('dc1', { parent_id: 'dh1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'dc1')
expect(node?.parentId).toBe('dh1')
expect(node?.extent).toBe('parent')
})
it('updateNode updates data fields', () => { it('updateNode updates data fields', () => {
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' })) useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' }) useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
@@ -84,7 +110,7 @@ describe('canvasStore', () => {
it('updateNode clearing parent_id converts position to absolute and clears parentId', () => { it('updateNode clearing parent_id converts position to absolute and clears parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 30, y: 40 }, parentId: 'px1', extent: 'parent' as const } const lxc = { ...makeNode('lxc1', { type: 'lxc', parent_id: 'px1' }), position: { x: 130, y: 140 }, parentId: 'px1', extent: 'parent' as const }
useCanvasStore.getState().addNode(proxmox) useCanvasStore.getState().addNode(proxmox)
useCanvasStore.getState().addNode(lxc) useCanvasStore.getState().addNode(lxc)
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined }) useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
@@ -214,7 +240,7 @@ describe('canvasStore', () => {
}) })
it('deleteNode also removes children with matching parentId', () => { it('deleteNode also removes children with matching parentId', () => {
useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
useCanvasStore.getState().deleteNode('parent') useCanvasStore.getState().deleteNode('parent')
const { nodes } = useCanvasStore.getState() const { nodes } = useCanvasStore.getState()
@@ -222,8 +248,8 @@ describe('canvasStore', () => {
expect(nodes.find((n) => n.id === 'child')).toBeUndefined() expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
}) })
it('addNode with parent_id sets parentId and extent', () => { it('addNode with parent_id sets parentId and extent when parent is in container mode', () => {
useCanvasStore.getState().addNode(makeNode('parent')) useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' })) useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child') const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
expect(child?.parentId).toBe('parent') expect(child?.parentId).toBe('parent')
@@ -422,6 +448,26 @@ describe('canvasStore', () => {
expect(hasUnsavedChanges).toBe(true) expect(hasUnsavedChanges).toBe(true)
}) })
it('setProxmoxContainerMode ON sets width/height for docker_host (not just proxmox)', () => {
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: false } }
useCanvasStore.setState({ nodes: [host] })
useCanvasStore.getState().setProxmoxContainerMode('dh', true)
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
expect(updated?.data.container_mode).toBe(true)
expect(updated?.width).toBe(300)
expect(updated?.height).toBe(200)
})
it('setProxmoxContainerMode OFF clears width/height for docker_host', () => {
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, width: 300, height: 200, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: true } }
useCanvasStore.setState({ nodes: [host] })
useCanvasStore.getState().setProxmoxContainerMode('dh', false)
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
expect(updated?.data.container_mode).toBe(false)
expect(updated?.width).toBeUndefined()
expect(updated?.height).toBeUndefined()
})
it('setProxmoxContainerMode ON nests children inside proxmox', () => { it('setProxmoxContainerMode ON nests children inside proxmox', () => {
const proxmox: Node<NodeData> = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } } const proxmox: Node<NodeData> = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } }
const child = makeNode('vm1', { parent_id: 'px', type: 'vm' }) const child = makeNode('vm1', { parent_id: 'px', type: 'vm' })
@@ -676,3 +722,98 @@ describe('canvasStore', () => {
expect(updated?.sourceHandle).toBe('bottom') expect(updated?.sourceHandle).toBe('bottom')
}) })
}) })
describe('canvasStore — custom style apply', () => {
beforeEach(() => {
useCanvasStore.setState({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
past: [],
future: [],
clipboard: [],
})
})
const serverStyle = {
borderColor: '#ff0000',
borderOpacity: 1,
bgColor: '#111111',
bgOpacity: 1,
iconColor: '#ff0000',
iconOpacity: 1,
width: 220,
height: 90,
}
it('applyTypeNodeStyle updates matching nodes custom_colors', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { type: 'server' }), makeNode('n2', { type: 'proxmox' })],
edges: [],
})
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
const n2 = useCanvasStore.getState().nodes.find((n) => n.id === 'n2')!
expect(n1.data.custom_colors?.border).toBe('#ff0000')
expect(n1.width).toBe(220)
expect(n1.height).toBe(90)
expect(n2.data.custom_colors?.border).toBeUndefined()
})
it('applyTypeNodeStyle with opacity < 1 produces rgba', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { type: 'server' })], edges: [] })
useCanvasStore.getState().applyTypeNodeStyle('server', { ...serverStyle, borderOpacity: 0.5 })
const n1 = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(n1.data.custom_colors?.border).toMatch(/^rgba\(/)
})
it('applyTypeNodeStyle marks canvas unsaved', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [] })
useCanvasStore.getState().applyTypeNodeStyle('server', serverStyle)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('applyTypeEdgeStyle updates matching edges', () => {
const e1: Edge<EdgeData> = { id: 'e1', source: 'n1', target: 'n2', type: 'ethernet', data: { type: 'ethernet' } }
const e2: Edge<EdgeData> = { id: 'e2', source: 'n1', target: 'n2', type: 'wifi', data: { type: 'wifi' } }
useCanvasStore.setState({ nodes: [], edges: [e1, e2] })
useCanvasStore.getState().applyTypeEdgeStyle('ethernet', { color: '#00ff00', opacity: 1, pathStyle: 'smooth', animated: 'flow' })
const updated1 = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
const updated2 = useCanvasStore.getState().edges.find((e) => e.id === 'e2')!
expect(updated1.data?.custom_color).toBe('#00ff00')
expect(updated1.data?.path_style).toBe('smooth')
expect(updated1.data?.animated).toBe('flow')
expect(updated2.data?.custom_color).toBeUndefined()
})
it('applyAllCustomStyles applies all defined types', () => {
const proxmoxNode = makeNode('np', { type: 'proxmox' })
const serverNode = makeNode('ns', { type: 'server' })
const e1: Edge<EdgeData> = { id: 'e1', source: 'np', target: 'ns', type: 'ethernet', data: { type: 'ethernet' } }
useCanvasStore.setState({ nodes: [proxmoxNode, serverNode], edges: [e1] })
useCanvasStore.getState().applyAllCustomStyles({
nodes: {
proxmox: { borderColor: '#ff6e00', borderOpacity: 1, bgColor: '#111', bgOpacity: 1, iconColor: '#ff6e00', iconOpacity: 1, width: 0, height: 0 },
},
edges: {
ethernet: { color: '#aabbcc', opacity: 1, pathStyle: 'bezier', animated: 'none' },
},
})
const np = useCanvasStore.getState().nodes.find((n) => n.id === 'np')!
const ns = useCanvasStore.getState().nodes.find((n) => n.id === 'ns')!
const e = useCanvasStore.getState().edges.find((e) => e.id === 'e1')!
expect(np.data.custom_colors?.border).toBe('#ff6e00')
expect(ns.data.custom_colors?.border).toBeUndefined()
expect(e.data?.custom_color).toBe('#aabbcc')
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
})
@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import type { CustomStyleDef } from '@/types'
describe('themeStore', () => { describe('themeStore', () => {
beforeEach(() => { beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' }) useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } })
}) })
it('starts with default theme', () => { it('starts with default theme', () => {
@@ -15,8 +16,8 @@ describe('themeStore', () => {
expect(useThemeStore.getState().activeTheme).toBe('matrix') expect(useThemeStore.getState().activeTheme).toBe('matrix')
}) })
it('setTheme can switch between all presets', () => { it('setTheme can switch between all presets including custom', () => {
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const
for (const id of themes) { for (const id of themes) {
useThemeStore.getState().setTheme(id) useThemeStore.getState().setTheme(id)
expect(useThemeStore.getState().activeTheme).toBe(id) expect(useThemeStore.getState().activeTheme).toBe(id)
@@ -28,4 +29,26 @@ describe('themeStore', () => {
useThemeStore.getState().setTheme('default') useThemeStore.getState().setTheme('default')
expect(useThemeStore.getState().activeTheme).toBe('default') expect(useThemeStore.getState().activeTheme).toBe('default')
}) })
it('starts with empty customStyle', () => {
const { customStyle } = useThemeStore.getState()
expect(customStyle.nodes).toEqual({})
expect(customStyle.edges).toEqual({})
})
it('setCustomStyle replaces the entire definition', () => {
const def: CustomStyleDef = {
nodes: { server: { borderColor: '#ff0000', borderOpacity: 1, bgColor: '#000000', bgOpacity: 1, iconColor: '#ff0000', iconOpacity: 1, width: 200, height: 80 } },
edges: { ethernet: { color: '#00ff00', opacity: 0.8, pathStyle: 'bezier', animated: 'none' } },
}
useThemeStore.getState().setCustomStyle(def)
expect(useThemeStore.getState().customStyle.nodes.server?.borderColor).toBe('#ff0000')
expect(useThemeStore.getState().customStyle.edges.ethernet?.color).toBe('#00ff00')
})
it('setCustomStyle with empty def clears styles', () => {
useThemeStore.getState().setCustomStyle({ nodes: { server: { borderColor: '#aaa', borderOpacity: 1, bgColor: '#000', bgOpacity: 1, iconColor: '#aaa', iconOpacity: 1, width: 0, height: 0 } }, edges: {} })
useThemeStore.getState().setCustomStyle({ nodes: {}, edges: {} })
expect(useThemeStore.getState().customStyle.nodes).toEqual({})
})
}) })
+119 -4
View File
@@ -9,9 +9,10 @@ import {
applyEdgeChanges, applyEdgeChanges,
addEdge, addEdge,
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] } type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -58,6 +59,9 @@ interface CanvasState {
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
hideIp: boolean hideIp: boolean
toggleHideIp: () => void toggleHideIp: () => void
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
applyAllCustomStyles: (def: CustomStyleDef) => void
} }
export const useCanvasStore = create<CanvasState>((set) => ({ export const useCanvasStore = create<CanvasState>((set) => ({
@@ -172,8 +176,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
addNode: (node) => addNode: (node) =>
set((state) => { set((state) => {
const enriched = node.data.parent_id const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const } const shouldNestInParent = !!(parent?.data.container_mode)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
},
}
: node : node
// Parents must come before children in the array (React Flow requirement) // Parents must come before children in the array (React Flow requirement)
const withoutNew = state.nodes.filter((n) => n.id !== node.id) const withoutNew = state.nodes.filter((n) => n.id !== node.id)
@@ -283,14 +297,37 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setProxmoxContainerMode: (proxmoxId, enabled) => setProxmoxContainerMode: (proxmoxId, enabled) =>
set((state) => { set((state) => {
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
let nodes = state.nodes.map((n) => { let nodes = state.nodes.map((n) => {
if (n.id === proxmoxId) { if (n.id === proxmoxId) {
const withMode = { ...n, data: { ...n.data, container_mode: enabled } } const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
return enabled return enabled
? { ...withMode, width: 300, height: 200 } ? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
: { ...withMode, width: undefined, height: undefined } : { ...withMode, width: undefined, height: undefined }
} }
if (n.data.parent_id === proxmoxId) { if (n.data.parent_id === proxmoxId) {
if (enabled && parentNode) {
return {
...n,
parentId: proxmoxId,
extent: 'parent' as const,
position: {
x: Math.max(10, n.position.x - parentNode.position.x),
y: Math.max(10, n.position.y - parentNode.position.y),
},
}
}
if (!enabled && parentNode) {
return {
...n,
parentId: undefined,
extent: undefined,
position: {
x: parentNode.position.x + n.position.x,
y: parentNode.position.y + n.position.y,
},
}
}
return enabled return enabled
? { ...n, parentId: proxmoxId, extent: 'parent' as const } ? { ...n, parentId: proxmoxId, extent: 'parent' as const }
: { ...n, parentId: undefined, extent: undefined } : { ...n, parentId: undefined, extent: undefined }
@@ -435,4 +472,82 @@ export const useCanvasStore = create<CanvasState>((set) => ({
}, },
clearFitViewPending: () => set({ fitViewPending: false }), clearFitViewPending: () => set({ fitViewPending: false }),
applyTypeNodeStyle: (nodeType, style) =>
set((state) => ({
nodes: state.nodes.map((n) => {
if (n.data.type !== nodeType) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
}),
hasUnsavedChanges: true,
})),
applyTypeEdgeStyle: (edgeType, style) =>
set((state) => ({
edges: state.edges.map((e) => {
if ((e.data?.type ?? 'ethernet') !== edgeType) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
}),
hasUnsavedChanges: true,
})),
applyAllCustomStyles: (def) =>
set((state) => {
const nodes = state.nodes.map((n) => {
const style = def.nodes[n.data.type]
if (!style) return n
return {
...n,
width: style.width > 0 ? style.width : n.width,
height: style.height > 0 ? style.height : n.height,
data: {
...n.data,
custom_colors: {
...n.data.custom_colors,
border: applyOpacity(style.borderColor, style.borderOpacity),
background: applyOpacity(style.bgColor, style.bgOpacity),
icon: applyOpacity(style.iconColor, style.iconOpacity),
},
},
}
})
const edges = state.edges.map((e) => {
const edgeType = (e.data?.type ?? 'ethernet') as EdgeType
const style = def.edges[edgeType]
if (!style) return e
return {
...e,
data: {
...e.data,
type: edgeType,
custom_color: applyOpacity(style.color, style.opacity),
path_style: style.pathStyle,
animated: style.animated,
} as EdgeData,
}
})
return { nodes, edges, hasUnsavedChanges: true }
}),
})) }))
+5
View File
@@ -1,12 +1,17 @@
import { create } from 'zustand' import { create } from 'zustand'
import type { ThemeId } from '@/utils/themes' import type { ThemeId } from '@/utils/themes'
import type { CustomStyleDef } from '@/types'
interface ThemeState { interface ThemeState {
activeTheme: ThemeId activeTheme: ThemeId
setTheme: (id: ThemeId) => void setTheme: (id: ThemeId) => void
customStyle: CustomStyleDef
setCustomStyle: (def: CustomStyleDef) => void
} }
export const useThemeStore = create<ThemeState>((set) => ({ export const useThemeStore = create<ThemeState>((set) => ({
activeTheme: 'default', activeTheme: 'default',
setTheme: (id) => set({ activeTheme: id }), setTheme: (id) => set({ activeTheme: id }),
customStyle: { nodes: {}, edges: {} },
setCustomStyle: (def) => set({ customStyle: def }),
})) }))
+29 -3
View File
@@ -13,7 +13,8 @@ export type NodeType =
| 'printer' | 'printer'
| 'computer' | 'computer'
| 'cpl' | 'cpl'
| 'docker' | 'docker_host'
| 'docker_container'
| 'generic' | 'generic'
| 'groupRect' | 'groupRect'
| 'group' | 'group'
@@ -36,9 +37,10 @@ export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none' export type CheckMethod = 'ping' | 'http' | 'https' | 'tcp' | 'ssh' | 'prometheus' | 'health' | 'none'
export interface ServiceInfo { export interface ServiceInfo {
port: number port?: number
protocol: 'tcp' | 'udp' protocol: 'tcp' | 'udp'
service_name: string service_name: string
path?: string
icon?: string icon?: string
category?: string category?: string
} }
@@ -126,7 +128,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
printer: 'Printer', printer: 'Printer',
computer: 'Computer', computer: 'Computer',
cpl: 'CPL / Powerline', cpl: 'CPL / Powerline',
docker: 'Docker Host', docker_host: 'Docker Host',
docker_container: 'Docker Container',
generic: 'Generic Device', generic: 'Generic Device',
groupRect: 'Group Rectangle', groupRect: 'Group Rectangle',
group: 'Node Group', group: 'Node Group',
@@ -147,3 +150,26 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
virtual: 'Virtual', virtual: 'Virtual',
cluster: 'Cluster', cluster: 'Cluster',
} }
export interface NodeTypeStyle {
borderColor: string
borderOpacity: number
bgColor: string
bgOpacity: number
iconColor: string
iconOpacity: number
width: number
height: number
}
export interface EdgeTypeStyle {
color: string
opacity: number
pathStyle: EdgePathStyle
animated: 'none' | 'snake' | 'flow' | 'basic'
}
export interface CustomStyleDef {
nodes: Partial<Record<NodeType, NodeTypeStyle>>
edges: Partial<Record<EdgeType, EdgeTypeStyle>>
}
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { hexToRgba, rgbaToHex8 } from '../colorUtils'
describe('hexToRgba', () => {
it('splits 8-digit hex into hex6 and alpha', () => {
const { hex6, alpha } = hexToRgba('#00d4ff0d')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(5)
})
it('handles fully opaque 8-digit hex (ff)', () => {
const { hex6, alpha } = hexToRgba('#00d4ffff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles fully transparent 8-digit hex (00)', () => {
const { hex6, alpha } = hexToRgba('#00d4ff00')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(0)
})
it('defaults alpha to 100 for 6-digit hex', () => {
const { hex6, alpha } = hexToRgba('#00d4ff')
expect(hex6).toBe('#00d4ff')
expect(alpha).toBe(100)
})
it('handles 6-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e00')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(100)
})
it('handles 8-digit hex without leading #', () => {
const { hex6, alpha } = hexToRgba('ff6e0080')
expect(hex6).toBe('#ff6e00')
expect(alpha).toBe(50)
})
it('returns fallback for invalid input', () => {
const { hex6, alpha } = hexToRgba('invalid')
expect(hex6).toBe('#000000')
expect(alpha).toBe(100)
})
it('is case-insensitive', () => {
const { hex6 } = hexToRgba('#00D4FF0D')
expect(hex6).toBe('#00D4FF')
})
})
describe('rgbaToHex8', () => {
it('combines hex6 and alpha into 8-digit hex', () => {
expect(rgbaToHex8('#00d4ff', 5)).toBe('#00d4ff0d')
})
it('produces ff for alpha 100', () => {
expect(rgbaToHex8('#00d4ff', 100)).toBe('#00d4ffff')
})
it('produces 00 for alpha 0', () => {
expect(rgbaToHex8('#00d4ff', 0)).toBe('#00d4ff00')
})
it('produces 80 for alpha 50', () => {
expect(rgbaToHex8('#ff6e00', 50)).toBe('#ff6e0080')
})
it('clamps alpha below 0 to 0', () => {
expect(rgbaToHex8('#ffffff', -10)).toBe('#ffffff00')
})
it('clamps alpha above 100 to 100', () => {
expect(rgbaToHex8('#ffffff', 150)).toBe('#ffffffff')
})
it('pads single-digit alpha hex with leading zero', () => {
const result = rgbaToHex8('#000000', 1)
const alphaPart = result.slice(7)
expect(alphaPart.length).toBe(2)
})
})
describe('round-trip', () => {
it('hexToRgba → rgbaToHex8 round-trips correctly', () => {
const original = '#00d4ff0d'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
it('round-trips fully opaque color', () => {
const original = '#a855f7ff'
const { hex6, alpha } = hexToRgba(original)
expect(rgbaToHex8(hex6, alpha)).toBe(original)
})
})
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { exportToPng, EXPORT_QUALITY_OPTIONS } from '../export'
const mockToPng = vi.fn()
vi.mock('html-to-image', () => ({ toPng: (...args: unknown[]) => mockToPng(...args) }))
describe('exportToPng', () => {
let el: HTMLElement
let clickSpy: ReturnType<typeof vi.fn>
let appendSpy: ReturnType<typeof vi.spyOn>
let createSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
el = document.createElement('div')
clickSpy = vi.fn()
createSpy = vi.spyOn(document, 'createElement').mockReturnValue(
Object.assign(document.createElement('a'), { click: clickSpy }) as HTMLAnchorElement
)
appendSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((n) => n)
mockToPng.mockResolvedValue('data:image/png;base64,abc')
})
afterEach(() => {
createSpy.mockRestore()
appendSpy.mockRestore()
})
it('calls toPng with pixelRatio 1 for standard quality', async () => {
await exportToPng(el, 'standard')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 1 }))
})
it('calls toPng with pixelRatio 2 for high quality', async () => {
await exportToPng(el, 'high')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
})
it('calls toPng with pixelRatio 4 for ultra quality', async () => {
await exportToPng(el, 'ultra')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 4 }))
})
it('defaults to high quality when no quality arg given', async () => {
await exportToPng(el)
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ pixelRatio: 2 }))
})
it('triggers a download with the correct filename', async () => {
await exportToPng(el, 'high')
expect(clickSpy).toHaveBeenCalled()
})
it('passes dark background color', async () => {
await exportToPng(el, 'standard')
expect(mockToPng).toHaveBeenCalledWith(el, expect.objectContaining({ backgroundColor: '#0d1117' }))
})
})
describe('EXPORT_QUALITY_OPTIONS', () => {
it('has exactly three options', () => {
expect(EXPORT_QUALITY_OPTIONS).toHaveLength(3)
})
it('options are standard, high, ultra in order', () => {
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.value)).toEqual(['standard', 'high', 'ultra'])
})
it('pixel ratios are 1, 2, 4', () => {
expect(EXPORT_QUALITY_OPTIONS.map((o) => o.pixelRatio)).toEqual([1, 2, 4])
})
})
@@ -1,12 +1,17 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { Cpu, HardDrive, MemoryStick } from 'lucide-react' import { CircuitBoard, Cpu, EthernetPort, Gpu, HardDrive, HdmiPort, MemoryStick, Usb } from 'lucide-react'
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
describe('PROPERTY_ICONS', () => { describe('PROPERTY_ICONS', () => {
it('contains the hardware migration icons', () => { it('contains the hardware migration icons', () => {
expect(PROPERTY_ICONS['CircuitBoard']).toBe(CircuitBoard)
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu) expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
expect(PROPERTY_ICONS['EthernetPort']).toBe(EthernetPort)
expect(PROPERTY_ICONS['Gpu']).toBe(Gpu)
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive) expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
expect(PROPERTY_ICONS['HdmiPort']).toBe(HdmiPort)
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick) expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
expect(PROPERTY_ICONS['Usb']).toBe(Usb)
}) })
it('has at least 10 icons', () => { it('has at least 10 icons', () => {
+2 -2
View File
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [ const NODE_TYPES: NodeType[] = [
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
] ]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
@@ -84,7 +84,7 @@ describe('THEMES', () => {
expect(d.nodeAccents.server.border).toBe('#a855f7') expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff') expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00') expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeAccents.docker.border).toBe('#2496ED') expect(d.nodeAccents.docker_host.border).toBe('#2496ED')
expect(d.nodeCardBackground).toBe('#21262d') expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22') expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117') expect(d.canvasBackground).toBe('#0d1117')
+8 -4
View File
@@ -134,6 +134,7 @@ export function deserializeApiNode(
n: ApiNode, n: ApiNode,
proxmoxContainerMap: Map<string, boolean>, proxmoxContainerMap: Map<string, boolean>,
): Node<NodeData> { ): Node<NodeData> {
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
if (n.type === 'groupRect') { if (n.type === 'groupRect') {
const w = (n.custom_colors?.width as number | undefined) ?? 360 const w = (n.custom_colors?.width as number | undefined) ?? 360
const h = (n.custom_colors?.height as number | undefined) ?? 240 const h = (n.custom_colors?.height as number | undefined) ?? 240
@@ -152,12 +153,15 @@ export function deserializeApiNode(
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return { return {
id: n.id, id: n.id,
type: n.type, type: normalizedType,
position: { x: n.pos_x, y: n.pos_y }, position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData, data: { ...n, type: normalizedType } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(n.width ? { width: n.width } : n.type === 'proxmox' && n.container_mode !== false ? { width: 300 } : {}), ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}), ? { width: n.width ?? 300, height: n.height ?? 200 }
: {}),
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}),
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}),
} }
} }
+43
View File
@@ -0,0 +1,43 @@
/**
* Split a 6- or 8-digit hex color into its RGB hex and alpha (0100).
* 6-digit input returns alpha 100.
* Invalid input returns { hex6: '#000000', alpha: 100 }.
*/
export function hexToRgba(hex: string): { hex6: string; alpha: number } {
const clean = hex.replace('#', '')
if (clean.length === 8) {
const alphaByte = parseInt(clean.slice(6, 8), 16)
return {
hex6: `#${clean.slice(0, 6)}`,
alpha: Math.round((alphaByte / 255) * 100),
}
}
if (clean.length === 6) {
return { hex6: `#${clean}`, alpha: 100 }
}
return { hex6: '#000000', alpha: 100 }
}
/**
* Combine a 6-digit hex color and an alpha (0100) into an 8-digit hex.
*/
export function rgbaToHex8(hex6: string, alpha: number): string {
const clamped = Math.max(0, Math.min(100, alpha))
const alphaByte = Math.round((clamped / 100) * 255)
const alphaHex = alphaByte.toString(16).padStart(2, '0')
return `${hex6}${alphaHex}`
}
/**
* Combine a hex color and opacity (01) into a CSS rgba() string.
* Returns the plain hex when opacity is 1.
*/
export function applyOpacity(hex: string, opacity: number): string {
if (opacity >= 1) return hex
let h = hex.replace('#', '')
if (h.length === 3) h = h[0]+h[0]+h[1]+h[1]+h[2]+h[2]
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${Math.round(opacity * 100) / 100})`
}
+11 -6
View File
@@ -1,14 +1,19 @@
import { toPng } from 'html-to-image' import { toPng } from 'html-to-image'
/** export type ExportQuality = 'standard' | 'high' | 'ultra'
* Export the React Flow canvas as a PNG and trigger a browser download.
* Pass the `.react-flow` wrapper element. export const EXPORT_QUALITY_OPTIONS: { value: ExportQuality; label: string; pixelRatio: number; hint: string }[] = [
*/ { value: 'standard', label: 'Standard', pixelRatio: 1, hint: '1× — small file' },
export async function exportToPng(element: HTMLElement): Promise<void> { { value: 'high', label: 'High', pixelRatio: 2, hint: '2× — recommended' },
{ value: 'ultra', label: 'Ultra', pixelRatio: 4, hint: '4× — print quality, large file' },
]
export async function exportToPng(element: HTMLElement, quality: ExportQuality = 'high'): Promise<void> {
const option = EXPORT_QUALITY_OPTIONS.find((o) => o.value === quality) ?? EXPORT_QUALITY_OPTIONS[1]
const dataUrl = await toPng(element, { const dataUrl = await toPng(element, {
backgroundColor: '#0d1117', backgroundColor: '#0d1117',
pixelRatio: option.pixelRatio,
style: { style: {
// Exclude controls from the export
'--xy-controls-display': 'none', '--xy-controls-display': 'none',
} as Partial<CSSStyleDeclaration>, } as Partial<CSSStyleDeclaration>,
}) })
+5 -1
View File
@@ -15,7 +15,11 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
.map((n) => { .map((n) => {
const d = n.data const d = n.data
const services = d.services?.length const services = d.services?.length
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ') ? d.services.map((s) => {
const port = s.port != null ? `:${s.port}` : ''
const path = s.path?.trim() ? s.path.trim() : ''
return `${s.service_name}${port}${path}`
}).join(', ')
: EMPTY : EMPTY
return [ return [
cell(d.label), cell(d.label),
+59 -58
View File
@@ -32,21 +32,21 @@ export interface IconEntry {
export const ICON_REGISTRY: IconEntry[] = [ export const ICON_REGISTRY: IconEntry[] = [
// --- Infrastructure --- // --- Infrastructure ---
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe }, { key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router }, { key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network }, { key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server }, { key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers }, { key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box }, { key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container }, { key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive }, { key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu }, { key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi }, { key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle }, { key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor }, { key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone }, { key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer }, { key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap }, { key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
// --- Media --- // --- Media ---
{ key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play }, { key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play },
@@ -62,44 +62,44 @@ export const ICON_REGISTRY: IconEntry[] = [
{ key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv }, { key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv },
// --- Monitoring --- // --- Monitoring ---
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity }, { key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 }, { key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart }, { key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
{ key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye }, { key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye },
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell }, { key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge }, { key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
// --- Storage & Databases --- // --- Storage & Databases ---
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database }, { key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive }, { key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud }, { key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen }, { key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
{ key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download }, { key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download },
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload }, { key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw }, { key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
// --- Security & Auth --- // --- Security & Auth ---
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield }, { key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck }, { key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
{ key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock }, { key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock },
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key }, { key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users }, { key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck }, { key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter }, { key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search }, { key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
// --- Automation & Smart Home --- // --- Automation & Smart Home ---
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home }, { key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap }, { key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow }, { key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot }, { key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer }, { key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb }, { key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio }, { key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
// --- Containers & Dev --- // --- Containers & Dev ---
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor }, { key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package }, { key: 'package', label: 'Docker Host / Container', category: 'Dev & Containers', icon: Package },
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch }, { key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal }, { key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 }, { key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
@@ -118,24 +118,25 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
) )
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = { export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe, isp: Globe,
router: Router, router: Router,
switch: Network, switch: Network,
server: Server, server: Server,
proxmox: Layers, proxmox: Layers,
vm: Box, vm: Box,
lxc: Container, lxc: Container,
nas: HardDrive, nas: HardDrive,
iot: Cpu, iot: Cpu,
ap: Wifi, ap: Wifi,
camera: Cctv, camera: Cctv,
printer: Printer, printer: Printer,
computer: Monitor, computer: Monitor,
cpl: PlugZap, cpl: PlugZap,
docker: Anchor, docker_host: Anchor,
generic: Circle, docker_container: Package,
group: Circle, generic: Circle,
groupRect: Circle, group: Circle,
groupRect: Circle,
} }
/** Resolve the display icon for a node — custom_icon takes priority over type default. */ /** Resolve the display icon for a node — custom_icon takes priority over type default. */
+10
View File
@@ -1,11 +1,15 @@
import { import {
Battery, Battery,
Box, Box,
CircuitBoard,
Clock, Clock,
Cpu, Cpu,
Database, Database,
EthernetPort,
Globe, Globe,
Gpu,
HardDrive, HardDrive,
HdmiPort,
Hash, Hash,
Key, Key,
Layers, Layers,
@@ -17,6 +21,7 @@ import {
Shield, Shield,
Tag, Tag,
Thermometer, Thermometer,
Usb,
Wifi, Wifi,
Zap, Zap,
} from 'lucide-react' } from 'lucide-react'
@@ -25,11 +30,15 @@ import type { LucideIcon } from 'lucide-react'
export const PROPERTY_ICONS: Record<string, LucideIcon> = { export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Battery, Battery,
Box, Box,
CircuitBoard,
Clock, Clock,
Cpu, Cpu,
Database, Database,
EthernetPort,
Globe, Globe,
Gpu,
HardDrive, HardDrive,
HdmiPort,
Hash, Hash,
Key, Key,
Layers, Layers,
@@ -41,6 +50,7 @@ export const PROPERTY_ICONS: Record<string, LucideIcon> = {
Shield, Shield,
Tag, Tag,
Thermometer, Thermometer,
Usb,
Wifi, Wifi,
Zap, Zap,
} }
+72 -5
View File
@@ -20,15 +20,82 @@ const NON_HTTP_PORTS = new Set([
27017, 27018, // MongoDB 27017, 27018, // MongoDB
]) ])
function splitFirstHost(host: string): string {
return host.split(',')[0]?.trim() ?? ''
}
function parsePort(port: string): number | undefined {
if (!/^\d+$/.test(port)) return undefined
const parsed = Number.parseInt(port, 10)
return parsed >= 1 && parsed <= 65535 ? parsed : undefined
}
function parseHostParts(host: string): { protocol?: 'http' | 'https'; hostname: string; port?: number } | null {
const firstHost = splitFirstHost(host)
if (!firstHost) return null
if (firstHost.startsWith('http://') || firstHost.startsWith('https://')) {
const url = new URL(firstHost)
return {
protocol: url.protocol === 'https:' ? 'https' : 'http',
hostname: url.hostname,
port: parsePort(url.port),
}
}
if (firstHost.startsWith('[')) {
const bracketIndex = firstHost.indexOf(']')
if (bracketIndex === -1) return { hostname: firstHost }
const hostname = firstHost.slice(1, bracketIndex)
const remainder = firstHost.slice(bracketIndex + 1)
return {
hostname,
port: remainder.startsWith(':') ? parsePort(remainder.slice(1)) : undefined,
}
}
const colonCount = (firstHost.match(/:/g) ?? []).length
if (colonCount === 1) {
const [hostname, rawPort] = firstHost.split(':')
const parsedPort = parsePort(rawPort)
if (hostname && parsedPort != null) {
return { hostname, port: parsedPort }
}
}
return { hostname: firstHost }
}
function normalizePath(path?: string): string {
const trimmed = path?.trim()
if (!trimmed) return ''
if (trimmed === '/') return '/'
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
}
function formatHostname(hostname: string): string {
return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname
}
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null { export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
if (!host) return null if (!host) return null
if (svc.port === 22) return null // SSH — no browser
if (svc.protocol === 'udp') return null // UDP — not HTTP if (svc.protocol === 'udp') return null // UDP — not HTTP
if (NON_HTTP_PORTS.has(svc.port)) return null
const parts = parseHostParts(host)
if (!parts?.hostname) return null
const effectivePort = svc.port ?? parts.port
if (effectivePort === 22) return null // SSH — no browser
if (effectivePort != null && NON_HTTP_PORTS.has(effectivePort)) return null
const name = svc.service_name.toLowerCase() const name = svc.service_name.toLowerCase()
const isHttps = const protocol = parts.protocol ?? (
name.includes('https') || name.includes('ssl') || name.includes('tls') || name.includes('https') || name.includes('ssl') || name.includes('tls') ||
svc.port === 443 || svc.port === 8443 effectivePort === 443 || effectivePort === 8443
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}` ? 'https'
: 'http'
)
const base = `${protocol}://${formatHostname(parts.hostname)}`
const port = effectivePort != null ? `:${effectivePort}` : ''
return `${base}${port}${normalizePath(svc.path)}`
} }
+153 -92
View File
@@ -1,6 +1,6 @@
import type { NodeType, EdgeType, NodeStatus } from '@/types' import type { NodeType, EdgeType, NodeStatus } from '@/types'
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix' | 'custom'
export interface ThemeColors { export interface ThemeColors {
// Per node-type accent (border + icon) // Per node-type accent (border + icon)
@@ -42,24 +42,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Dark futuristic — the original Homelable look', description: 'Dark futuristic — the original Homelable look',
colors: { colors: {
nodeAccents: { nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' }, isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' }, router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' }, switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' }, server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' }, proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' }, vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' }, lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' }, nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' }, iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' }, ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' }, camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' }, printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' }, computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' }, cpl: { border: '#e3b341', icon: '#e3b341' },
docker: { border: '#2496ED', icon: '#2496ED' }, docker_host: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#8b949e', icon: '#8b949e' }, docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' }, generic: { border: '#8b949e', icon: '#8b949e' },
group: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -97,24 +98,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Pure black with maximum contrast', description: 'Pure black with maximum contrast',
colors: { colors: {
nodeAccents: { nodeAccents: {
isp: { border: '#22d3ee', icon: '#22d3ee' }, isp: { border: '#22d3ee', icon: '#22d3ee' },
router: { border: '#22d3ee', icon: '#22d3ee' }, router: { border: '#22d3ee', icon: '#22d3ee' },
switch: { border: '#4ade80', icon: '#4ade80' }, switch: { border: '#4ade80', icon: '#4ade80' },
server: { border: '#c084fc', icon: '#c084fc' }, server: { border: '#c084fc', icon: '#c084fc' },
proxmox: { border: '#fb923c', icon: '#fb923c' }, proxmox: { border: '#fb923c', icon: '#fb923c' },
vm: { border: '#c084fc', icon: '#c084fc' }, vm: { border: '#c084fc', icon: '#c084fc' },
lxc: { border: '#22d3ee', icon: '#22d3ee' }, lxc: { border: '#22d3ee', icon: '#22d3ee' },
nas: { border: '#4ade80', icon: '#4ade80' }, nas: { border: '#4ade80', icon: '#4ade80' },
iot: { border: '#fbbf24', icon: '#fbbf24' }, iot: { border: '#fbbf24', icon: '#fbbf24' },
ap: { border: '#22d3ee', icon: '#22d3ee' }, ap: { border: '#22d3ee', icon: '#22d3ee' },
camera: { border: '#94a3b8', icon: '#94a3b8' }, camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' }, printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' }, computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' }, cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker: { border: '#2496ED', icon: '#2496ED' }, docker_host: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#94a3b8', icon: '#94a3b8' }, docker_container: { border: '#38bdf8', icon: '#38bdf8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' }, generic: { border: '#94a3b8', icon: '#94a3b8' },
group: { border: '#22d3ee', icon: '#22d3ee' }, groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
}, },
nodeCardBackground: '#0a0a0a', nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111', nodeIconBackground: '#111111',
@@ -152,24 +154,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Clean light theme with dark text', description: 'Clean light theme with dark text',
colors: { colors: {
nodeAccents: { nodeAccents: {
isp: { border: '#0284c7', icon: '#0284c7' }, isp: { border: '#0284c7', icon: '#0284c7' },
router: { border: '#0284c7', icon: '#0284c7' }, router: { border: '#0284c7', icon: '#0284c7' },
switch: { border: '#16a34a', icon: '#16a34a' }, switch: { border: '#16a34a', icon: '#16a34a' },
server: { border: '#7c3aed', icon: '#7c3aed' }, server: { border: '#7c3aed', icon: '#7c3aed' },
proxmox: { border: '#ea580c', icon: '#ea580c' }, proxmox: { border: '#ea580c', icon: '#ea580c' },
vm: { border: '#7c3aed', icon: '#7c3aed' }, vm: { border: '#7c3aed', icon: '#7c3aed' },
lxc: { border: '#0284c7', icon: '#0284c7' }, lxc: { border: '#0284c7', icon: '#0284c7' },
nas: { border: '#16a34a', icon: '#16a34a' }, nas: { border: '#16a34a', icon: '#16a34a' },
iot: { border: '#b45309', icon: '#b45309' }, iot: { border: '#b45309', icon: '#b45309' },
ap: { border: '#0284c7', icon: '#0284c7' }, ap: { border: '#0284c7', icon: '#0284c7' },
camera: { border: '#6b7280', icon: '#6b7280' }, camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' }, printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' }, computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' }, cpl: { border: '#b45309', icon: '#b45309' },
docker: { border: '#2496ED', icon: '#2496ED' }, docker_host: { border: '#2496ED', icon: '#2496ED' },
generic: { border: '#6b7280', icon: '#6b7280' }, docker_container: { border: '#0369a1', icon: '#0369a1' },
groupRect:{ border: '#0284c7', icon: '#0284c7' }, generic: { border: '#6b7280', icon: '#6b7280' },
group: { border: '#0284c7', icon: '#0284c7' }, groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
}, },
nodeCardBackground: '#ffffff', nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff', nodeIconBackground: '#f0f6ff',
@@ -207,24 +210,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Cyberpunk vibes with vivid glowing accents', description: 'Cyberpunk vibes with vivid glowing accents',
colors: { colors: {
nodeAccents: { nodeAccents: {
isp: { border: '#00ffff', icon: '#00ffff' }, isp: { border: '#00ffff', icon: '#00ffff' },
router: { border: '#00ffff', icon: '#00ffff' }, router: { border: '#00ffff', icon: '#00ffff' },
switch: { border: '#00ff80', icon: '#00ff80' }, switch: { border: '#00ff80', icon: '#00ff80' },
server: { border: '#ff00ff', icon: '#ff00ff' }, server: { border: '#ff00ff', icon: '#ff00ff' },
proxmox: { border: '#ff8800', icon: '#ff8800' }, proxmox: { border: '#ff8800', icon: '#ff8800' },
vm: { border: '#ff00ff', icon: '#ff00ff' }, vm: { border: '#ff00ff', icon: '#ff00ff' },
lxc: { border: '#00ffff', icon: '#00ffff' }, lxc: { border: '#00ffff', icon: '#00ffff' },
nas: { border: '#00ff80', icon: '#00ff80' }, nas: { border: '#00ff80', icon: '#00ff80' },
iot: { border: '#ffff00', icon: '#ffff00' }, iot: { border: '#ffff00', icon: '#ffff00' },
ap: { border: '#00ffff', icon: '#00ffff' }, ap: { border: '#00ffff', icon: '#00ffff' },
camera: { border: '#8888ff', icon: '#8888ff' }, camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' }, printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' }, computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' }, cpl: { border: '#ffff00', icon: '#ffff00' },
docker: { border: '#00aaff', icon: '#00aaff' }, docker_host: { border: '#00aaff', icon: '#00aaff' },
generic: { border: '#8888ff', icon: '#8888ff' }, docker_container: { border: '#00ddff', icon: '#00ddff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' }, generic: { border: '#8888ff', icon: '#8888ff' },
group: { border: '#00ffff', icon: '#00ffff' }, groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
}, },
nodeCardBackground: '#0f0f2a', nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a', nodeIconBackground: '#0a0a1a',
@@ -262,24 +266,25 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
description: 'Everything in terminal green', description: 'Everything in terminal green',
colors: { colors: {
nodeAccents: { nodeAccents: {
isp: { border: '#00ff41', icon: '#00ff41' }, isp: { border: '#00ff41', icon: '#00ff41' },
router: { border: '#00ff41', icon: '#00ff41' }, router: { border: '#00ff41', icon: '#00ff41' },
switch: { border: '#00cc33', icon: '#00cc33' }, switch: { border: '#00cc33', icon: '#00cc33' },
server: { border: '#008822', icon: '#008822' }, server: { border: '#008822', icon: '#008822' },
proxmox: { border: '#33ff66', icon: '#33ff66' }, proxmox: { border: '#33ff66', icon: '#33ff66' },
vm: { border: '#008822', icon: '#008822' }, vm: { border: '#008822', icon: '#008822' },
lxc: { border: '#00ff41', icon: '#00ff41' }, lxc: { border: '#00ff41', icon: '#00ff41' },
nas: { border: '#00cc33', icon: '#00cc33' }, nas: { border: '#00cc33', icon: '#00cc33' },
iot: { border: '#66ff33', icon: '#66ff33' }, iot: { border: '#66ff33', icon: '#66ff33' },
ap: { border: '#00ff41', icon: '#00ff41' }, ap: { border: '#00ff41', icon: '#00ff41' },
camera: { border: '#005500', icon: '#005500' }, camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' }, printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' }, computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' }, cpl: { border: '#66ff33', icon: '#66ff33' },
docker: { border: '#00cc88', icon: '#00cc88' }, docker_host: { border: '#00cc88', icon: '#00cc88' },
generic: { border: '#006600', icon: '#006600' }, docker_container: { border: '#00aacc', icon: '#00aacc' },
groupRect:{ border: '#00ff41', icon: '#00ff41' }, generic: { border: '#006600', icon: '#006600' },
group: { border: '#00ff41', icon: '#00ff41' }, groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' },
}, },
nodeCardBackground: '#001100', nodeCardBackground: '#001100',
nodeIconBackground: '#002200', nodeIconBackground: '#002200',
@@ -310,7 +315,63 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
reactFlowColorMode: 'dark', reactFlowColorMode: 'dark',
}, },
}, },
custom: {
id: 'custom',
label: 'Custom',
description: 'Your own colors per node and edge type',
colors: {
nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
nodeLabelColor: '#e6edf3',
nodeSubtextColor: '#8b949e',
statusColors: {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
},
edgeColors: {
ethernet: '#30363d',
wifi: '#00d4ff',
iot: '#e3b341',
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
edgeLabelColor: '#8b949e',
edgeLabelBorder: '#30363d',
canvasBackground: '#0d1117',
canvasDotColor: '#30363d',
handleBackground: '#30363d',
handleBorder: '#8b949e',
reactFlowColorMode: 'dark',
},
},
} }
// Ordered list for display in the modal // Ordered list for display in the modal
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix'] export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix', 'custom']
+13 -7
View File
@@ -1,7 +1,8 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI
from mcp.server import Server from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.routing import Mount
from .auth import ApiKeyMiddleware from .auth import ApiKeyMiddleware
from .backend_client import backend from .backend_client import backend
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
await backend.stop() await backend.stop()
app = FastAPI(title="Homelable MCP", lifespan=lifespan) # Mount the session manager as an ASGI sub-app instead of wrapping it in a
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
# FastAPI to send http.response.start after the session manager has already
# started the response, raising `RuntimeError: Unexpected ASGI message
# 'http.response.start' sent, after response already completed` on every
# POST /mcp — which makes the server unreachable from any MCP client.
app = FastAPI(
title="Homelable MCP",
lifespan=lifespan,
routes=[Mount("/mcp", app=session_manager.handle_request)],
)
app.add_middleware(ApiKeyMiddleware) app.add_middleware(ApiKeyMiddleware)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request):
await session_manager.handle_request(request.scope, request.receive, request._send)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}