Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cc97a6de9 | |||
| 29a2ef1b20 | |||
| 0c836e0575 | |||
| 6518eb313b | |||
| 09a591f5f4 | |||
| 988b804b90 | |||
| 6fa0ada325 | |||
| 17613f42d1 | |||
| 0a44b69c4e | |||
| 84235d81bf | |||
| 31b61904ac | |||
| babbcb1dc5 | |||
| 12fde681ba | |||
| 63922f0841 | |||
| 896cd4fa21 | |||
| 18f9bb7bdf | |||
| da287d459c | |||
| adb2088752 | |||
| cd0e08fb91 | |||
| 3ccdde0bea | |||
| d0a49d0a0d | |||
| 31b5bc4515 | |||
| b05d70663c | |||
| 4e68af7cac | |||
| 528c362633 | |||
| bfe520cd49 | |||
| 29c97ae501 | |||
| 4ecd241bf4 | |||
| 96786f155b | |||
| 1d6127fed3 | |||
| 26633f760d | |||
| 745002593f | |||
| 1a978c5e51 | |||
| 16adff5cff | |||
| 45b0965fb7 | |||
| f1bcd6ef78 | |||
| 26be37f731 | |||
| ebc1d41d5c | |||
| fe5e3c9858 | |||
| 78b47384da | |||
| 38e6604f50 | |||
| 0e70b45e8a | |||
| 7312132767 | |||
| cb5b1bd2e2 | |||
| c9a402fa0a | |||
| 9f880395da | |||
| 585df726e7 | |||
| 1a3cde3a02 | |||
| bfd7ccd36c | |||
| ada30311ed | |||
| a8ca4f1cca | |||
| feb05a1df5 | |||
| 0204a7ddeb | |||
| 6ead20125d | |||
| 03e1e32af1 | |||
| c9cd6a08fc | |||
| daa78a036a | |||
| 3deb750441 | |||
| 88554ef952 | |||
| 110592f89e | |||
| 8b8da5584c | |||
| 8626fb2ca4 | |||
| 5952274c27 | |||
| 9c035e2be2 | |||
| cad3add223 | |||
| fc888629c3 | |||
| 6a21cc729e | |||
| 06370529c6 | |||
| 4260a6582c | |||
| a0f18dd237 | |||
| 00edc32aeb | |||
| cd6a788f77 | |||
| 9cf6a48b04 | |||
| 2c94616afa | |||
| c7be851c34 | |||
| fddfd0a769 | |||
| 074b49358b | |||
| a47b7649f0 | |||
| 7e08a85f73 | |||
| c7c5183356 | |||
| 7608d07255 | |||
| 1bc6798d76 | |||
| f6de7d1770 | |||
| 9dddd00858 | |||
| a5bf9c9db6 | |||
| 892710faac | |||
| 431fb47498 |
+276
@@ -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.11–3.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.
|
||||||
@@ -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.
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+413
-3
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,25 +391,40 @@ 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>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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 }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Split a 6- or 8-digit hex color into its RGB hex and alpha (0–100).
|
||||||
|
* 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 (0–100) 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 (0–1) 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})`
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"}
|
||||||
|
|||||||
Reference in New Issue
Block a user