Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fb77ab00b | |||
| 56cfbd1e76 | |||
| 43761c60cb | |||
| ad958feabd | |||
| e3876e934c | |||
| dfa4a9c849 | |||
| 785be6a5dd | |||
| 39f8d16ef1 | |||
| 0095bf8425 | |||
| 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 | |||
| 8626fb2ca4 | |||
| 5952274c27 | |||
| 9c035e2be2 | |||
| cad3add223 | |||
| fc888629c3 | |||
| 6a21cc729e | |||
| 06370529c6 | |||
| 892710faac | |||
| 431fb47498 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: pouzor
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
+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.
|
||||
@@ -99,7 +99,7 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration) (optionnal)
|
||||
## MCP Server (AI Integration) (optional)
|
||||
|
||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||
|
||||
|
||||
@@ -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],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,13 +68,14 @@ async def save_canvas(
|
||||
else:
|
||||
db.add(Edge(**edge_data.model_dump()))
|
||||
|
||||
# Upsert viewport
|
||||
# Upsert viewport + custom style
|
||||
state = await db.get(CanvasState, 1)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.custom_style = body.custom_style
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
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()
|
||||
return {"saved": True}
|
||||
|
||||
@@ -84,6 +84,8 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||
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)
|
||||
with suppress(OperationalError):
|
||||
rows = await conn.exec_driver_sql(
|
||||
|
||||
@@ -78,6 +78,7 @@ class CanvasState(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -62,9 +62,11 @@ class CanvasSaveRequest(BaseModel):
|
||||
nodes: list[NodeSave] = []
|
||||
edges: list[EdgeSave] = []
|
||||
viewport: dict[str, Any] = {}
|
||||
custom_style: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CanvasStateResponse(BaseModel):
|
||||
nodes: list[NodeResponse]
|
||||
edges: list[EdgeResponse]
|
||||
viewport: dict[str, Any]
|
||||
custom_style: dict[str, Any] | None = None
|
||||
|
||||
@@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["label"] == "updated"
|
||||
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"]
|
||||
|
||||
Generated
+413
-3
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.8.2",
|
||||
"version": "1.10.2",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"axios": "^1.13.6",
|
||||
@@ -2011,6 +2012,415 @@
|
||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-rect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-effect-event": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/rect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-size": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-visually-hidden": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/rect": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -3024,7 +3434,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.10.2",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,6 +19,7 @@
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"axios": "^1.13.6",
|
||||
|
||||
+33
-22
@@ -29,16 +29,17 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi } from '@/api/client'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
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_STORAGE_KEY = 'homelable_canvas'
|
||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
@@ -59,20 +60,20 @@ export default function App() {
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
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()
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
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()
|
||||
toast.success('Canvas saved')
|
||||
} catch {
|
||||
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
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
@@ -84,8 +85,9 @@ export default function App() {
|
||||
try {
|
||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||
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 (custom_style) setCustomStyle(custom_style)
|
||||
loadCanvas(savedNodes, savedEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
@@ -100,23 +102,24 @@ export default function App() {
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
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>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
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
|
||||
const undoRef = useRef(undo)
|
||||
@@ -151,7 +154,7 @@ export default function App() {
|
||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||
snapshotHistory()
|
||||
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
|
||||
// Children position is relative to parent; place near top-left with padding
|
||||
const position = parentNode
|
||||
@@ -164,7 +167,7 @@ export default function App() {
|
||||
position,
|
||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||
...(isProxmox ? { width: 300, height: 200 } : {}),
|
||||
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||
}
|
||||
addNode(newNode)
|
||||
toast.success(`Added "${data.label}"`)
|
||||
@@ -242,13 +245,13 @@ export default function App() {
|
||||
snapshotHistory()
|
||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||
updateNode(editNodeId, data)
|
||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
|
||||
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (typeof data.container_mode === 'boolean') {
|
||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||
}
|
||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||
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 newParentId = data.parent_id ?? null
|
||||
if (oldParentId !== newParentId) {
|
||||
@@ -320,15 +323,19 @@ export default function App() {
|
||||
if (!pendingConnection) return
|
||||
snapshotHistory()
|
||||
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') {
|
||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||
const srcType = src?.data.type
|
||||
const tgtType = tgt?.data.type
|
||||
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
|
||||
const srcType = src?.data.type as NodeData['type']
|
||||
const tgtType = tgt?.data.type as NodeData['type']
|
||||
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -423,7 +430,9 @@ export default function App() {
|
||||
onClose={() => setAddNodeOpen(false)}
|
||||
onSubmit={handleAddNode}
|
||||
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 */}
|
||||
@@ -434,7 +443,9 @@ export default function App() {
|
||||
onSubmit={handleUpdateNode}
|
||||
initial={editNode?.data}
|
||||
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
|
||||
|
||||
@@ -33,6 +33,7 @@ export const canvasApi = {
|
||||
nodes: object[]
|
||||
edges: object[]
|
||||
viewport: object
|
||||
custom_style?: object | null
|
||||
}) => api.post('/canvas/save', payload),
|
||||
}
|
||||
|
||||
|
||||
@@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||
bottomHandlePositions: () => [50],
|
||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||
}))
|
||||
|
||||
beforeEach(() => { mockZoom = 1 })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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)', () => {
|
||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
|
||||
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 { useCanvasStore } from '@/stores/canvasStore'
|
||||
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']
|
||||
|
||||
@@ -161,9 +161,9 @@ function segmentMidpoints(
|
||||
const isSmooth = pathStyle === 'smooth'
|
||||
|
||||
return pts.slice(0, -1).map((a, i) => {
|
||||
const b = pts[i + 1]
|
||||
let mx = (a.x + b.x) / 2
|
||||
const my = (a.y + b.y) / 2
|
||||
const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
|
||||
let mx = base.x
|
||||
const my = base.y
|
||||
|
||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||
// 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)
|
||||
: autoPath
|
||||
|
||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
||||
const midY = (sourceY + targetY) / 2
|
||||
const labelPosition = hasWaypoints
|
||||
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||
: { x: labelX, y: (sourceY + targetY) / 2 }
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const edgeColors = theme.colors.edgeColors
|
||||
@@ -300,7 +301,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
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,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
|
||||
@@ -72,6 +72,216 @@ export function buildWaypointPath(
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||
|
||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
icon: LucideIcon
|
||||
@@ -56,7 +56,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||
: 'none',
|
||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||
minWidth: 140,
|
||||
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
||||
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
||||
width: width ? '100%' : undefined,
|
||||
height: height ? '100%' : undefined,
|
||||
}}
|
||||
@@ -66,7 +67,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
@@ -76,6 +77,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 }} />
|
||||
|
||||
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||
<div
|
||||
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||
{/* Icon */}
|
||||
@@ -121,7 +129,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
return (
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -139,7 +147,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
{data.cpu_model && (
|
||||
<span className="truncate max-w-[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 && (
|
||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||
@@ -166,16 +174,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||
const sourceId = bottomHandleId(idx)
|
||||
const targetId = `${sourceId}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
<Handle
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { BaseNode } from './BaseNode'
|
||||
@@ -13,6 +15,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const { data, selected } = props
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const theme = THEMES[activeTheme]
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
|
||||
@@ -53,7 +56,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
minHeight={160}
|
||||
isVisible={selected}
|
||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
|
||||
/>
|
||||
|
||||
{/* Group border */}
|
||||
@@ -71,7 +74,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{/* Header bar */}
|
||||
<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={{
|
||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||
@@ -93,18 +96,19 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
{data.ip && (
|
||||
{data.ip && splitIps(data.ip).map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="font-mono text-[9px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
>
|
||||
{data.ip}
|
||||
{hideIp ? maskIp(ip) : ip}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{/* Status dot */}
|
||||
<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 }}
|
||||
title={data.status}
|
||||
/>
|
||||
@@ -125,7 +129,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -10,6 +10,7 @@ type N = NodeProps<Node<NodeData>>
|
||||
|
||||
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
||||
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
|
||||
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||
@@ -22,5 +23,6 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const 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} />
|
||||
|
||||
@@ -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, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
@@ -6,6 +6,7 @@ import { GroupNode } from './GroupNode'
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
router: RouterNode,
|
||||
firewall: FirewallNode,
|
||||
switch: SwitchNode,
|
||||
server: ServerNode,
|
||||
proxmox: ProxmoxGroupNode,
|
||||
@@ -18,7 +19,8 @@ export const nodeTypes = {
|
||||
printer: PrinterNode,
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
docker: DockerNode,
|
||||
docker_host: DockerHostNode,
|
||||
docker_container: DockerContainerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
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, Flame,
|
||||
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', 'firewall', '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, firewall: Flame, 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 modalStyles from './modal-interactive.module.css'
|
||||
import { RotateCcw } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
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">
|
||||
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
|
||||
<SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{EDGE_TYPES.map(([value, label]) => (
|
||||
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
value={vlanId}
|
||||
onChange={(e) => setVlanId(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
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 className="flex flex-col gap-1.5">
|
||||
<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) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
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={{
|
||||
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
||||
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">
|
||||
<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) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
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={{
|
||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
)}
|
||||
</div>
|
||||
<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' }}
|
||||
tabIndex={0}
|
||||
aria-label="Edge color picker"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{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
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||
<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 cursor-pointer">
|
||||
{onDelete ? 'Save' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -129,7 +130,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
value={form.label}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
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>
|
||||
|
||||
@@ -137,7 +138,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||
<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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
@@ -162,7 +163,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
type="button"
|
||||
title={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={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -187,7 +188,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
key={value}
|
||||
type="button"
|
||||
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={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -248,7 +249,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
key={value}
|
||||
type="button"
|
||||
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={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -275,7 +276,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => set('border_style', value)}
|
||||
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
||||
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -301,7 +302,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
key={value}
|
||||
type="button"
|
||||
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={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
@@ -319,7 +320,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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)}>
|
||||
<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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
@@ -338,17 +339,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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() }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<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
|
||||
</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'}
|
||||
</Button>
|
||||
</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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -8,15 +9,28 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||
|
||||
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||
none: 'None',
|
||||
ping: 'Ping',
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
tcp: 'TCP',
|
||||
ssh: 'SSH',
|
||||
prometheus: 'Prometheus',
|
||||
health: 'Health',
|
||||
}
|
||||
|
||||
const DEFAULT_DATA: Partial<NodeData> = {
|
||||
type: 'server',
|
||||
@@ -26,7 +40,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
||||
status: 'unknown',
|
||||
check_method: 'ping',
|
||||
services: [],
|
||||
container_mode: true,
|
||||
container_mode: false,
|
||||
custom_colors: undefined,
|
||||
custom_icon: undefined,
|
||||
}
|
||||
@@ -37,12 +51,12 @@ interface NodeModalProps {
|
||||
onSubmit: (data: Partial<NodeData>) => void
|
||||
initial?: Partial<NodeData>
|
||||
title?: string
|
||||
proxmoxNodes?: { id: string; label: string }[]
|
||||
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
|
||||
}
|
||||
|
||||
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
||||
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||
// initial value is enough - no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
@@ -58,10 +72,19 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
return
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
const filteredParentNodes = form.type === 'docker_container'
|
||||
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
||||
: parentContainerNodes
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
||||
@@ -75,14 +98,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
||||
<SelectValue />
|
||||
<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>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||
<>
|
||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||
<SelectGroup key={group.label}>
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||
{group.label}
|
||||
</SelectLabel>
|
||||
@@ -92,7 +115,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -116,7 +139,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<button
|
||||
type="button"
|
||||
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">
|
||||
{(() => {
|
||||
@@ -132,14 +156,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline icon picker — full width, shown below the type+icon row */}
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<Input
|
||||
value={iconSearch}
|
||||
onChange={(e) => setIconSearch(e.target.value)}
|
||||
placeholder="Search icons…"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||
@@ -161,7 +185,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
type="button"
|
||||
title={entry.label}
|
||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
||||
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={{
|
||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||
@@ -189,7 +214,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.label ?? ''}
|
||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||
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>}
|
||||
</div>
|
||||
@@ -201,31 +226,32 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.hostname ?? ''}
|
||||
onChange={(e) => set('hostname', e.target.value)}
|
||||
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>
|
||||
|
||||
{/* IP */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
||||
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
||||
<Input
|
||||
value={form.ip ?? ''}
|
||||
onChange={(e) => set('ip', e.target.value)}
|
||||
placeholder="192.168.1.x, 2001:db8::1"
|
||||
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>
|
||||
|
||||
{/* Check method */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
||||
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{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>
|
||||
</Select>
|
||||
@@ -238,24 +264,28 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.check_target ?? ''}
|
||||
onChange={(e) => set('check_target', e.target.value)}
|
||||
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>
|
||||
|
||||
{/* Parent Proxmox (VM / LXC only) */}
|
||||
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
||||
{/* Parent container */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
|
||||
<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
|
||||
value={form.parent_id ?? 'none'}
|
||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue placeholder="None (standalone)" />
|
||||
<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)">
|
||||
{form.parent_id
|
||||
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
||||
: 'None (standalone)'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -263,24 +293,26 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container mode (proxmox only) */}
|
||||
{form.type === 'proxmox' && (
|
||||
{/* Container mode */}
|
||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.container_mode}
|
||||
onClick={() => set('container_mode', !form.container_mode)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer 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' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
||||
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -308,9 +340,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 items-center">
|
||||
<label
|
||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden 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' }}
|
||||
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
||||
tabIndex={0}
|
||||
aria-label={`Color picker for ${key}`}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
@@ -333,21 +367,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
{/* Bottom connection points (not for group containers) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<Select
|
||||
value={String(form.bottom_handles ?? 1)}
|
||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
||||
>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="1" className="text-sm">1 — center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 — left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 — left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 — evenly spaced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_BOTTOM_HANDLES}
|
||||
max={MAX_BOTTOM_HANDLES}
|
||||
step={1}
|
||||
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||
aria-label="Bottom connection points slider"
|
||||
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -358,25 +395,45 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
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 className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
>
|
||||
{title === 'Add Node' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{/* Show delete button only for edit mode (not add) */}
|
||||
{title !== 'Add Node' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete this node?')) {
|
||||
onSubmit({ ...form, _delete: true })
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
style={{ minWidth: 64 }}
|
||||
>
|
||||
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>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||
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 { Button } from '@/components/ui/button'
|
||||
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { CustomStyleModal } from './CustomStyleModal'
|
||||
|
||||
// Node-type accent colors to display as preview swatches
|
||||
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
||||
@@ -14,74 +15,108 @@ interface ThemeCardProps {
|
||||
themeId: ThemeId
|
||||
selected: boolean
|
||||
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 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
||||
style={{
|
||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||
background: c.canvasBackground,
|
||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Selected checkmark */}
|
||||
{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}` }}
|
||||
<div className="relative w-full h-full">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||
style={{
|
||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||
background: c.canvasBackground,
|
||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Node accent dots */}
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{PREVIEW_TYPES.map((type) => (
|
||||
<span
|
||||
key={type}
|
||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: c.nodeAccents[type].border }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Edge line */}
|
||||
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
||||
{/* Wifi dashed line */}
|
||||
{/* Selected checkmark */}
|
||||
{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
|
||||
style={{
|
||||
height: 1,
|
||||
width: '55%',
|
||||
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||
>
|
||||
<div className="flex gap-1 items-center flex-wrap">
|
||||
{swatchColors.map((color, i) => (
|
||||
<span
|
||||
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
|
||||
className="text-xs font-semibold leading-tight"
|
||||
style={{ color: c.nodeLabelColor }}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
||||
style={{ color: c.nodeSubtextColor }}
|
||||
>
|
||||
{preset.description}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className="text-sm font-semibold leading-tight wrap-break-word"
|
||||
style={{ color: c.nodeLabelColor }}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
|
||||
style={{ color: c.nodeSubtextColor }}
|
||||
>
|
||||
{preset.description}
|
||||
</div>
|
||||
</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) {
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
const { markUnsaved } = useCanvasStore()
|
||||
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
const [customStyleOpen, setCustomStyleOpen] = useState(false)
|
||||
|
||||
// Capture the theme that was active when the modal opened
|
||||
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||
@@ -100,68 +137,96 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||
|
||||
const handleSelect = (id: ThemeId) => {
|
||||
setSelected(id)
|
||||
// Live-preview the selected theme on the canvas
|
||||
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 = () => {
|
||||
setTheme(selected)
|
||||
markUnsaved()
|
||||
onClose()
|
||||
toast.info('Style applied — save your canvas to make it permanent', {
|
||||
duration: 5000,
|
||||
})
|
||||
toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// Revert to the original theme
|
||||
setTheme(originalTheme)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3 py-1">
|
||||
{THEME_ORDER.map((id) => (
|
||||
<ThemeCard
|
||||
key={id}
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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, index) => (
|
||||
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||
<ThemeCard
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
onKeyDown={handleCardKeyDown(index)}
|
||||
buttonRef={(element) => { cardRefs.current[index] = element }}
|
||||
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
style={
|
||||
selected !== 'default'
|
||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Style
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
style={
|
||||
selected !== 'default'
|
||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||
: undefined
|
||||
}
|
||||
onClick={handleApply}
|
||||
>
|
||||
Apply Style
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Delete confirm ────────────────────────────────────────────────────
|
||||
|
||||
it('deletes and closes when Delete confirm is accepted', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
// Regression: bare-if without braces used to call onClose() unconditionally,
|
||||
// closing the modal even when the user cancelled the confirm dialog.
|
||||
it('does not delete or close when Delete confirm is cancelled', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
|
||||
// ── Label validation ──────────────────────────────────────────────────
|
||||
|
||||
it('blocks submit and shows error when label is empty', () => {
|
||||
@@ -234,15 +256,18 @@ describe('NodeModal', () => {
|
||||
expect(screen.queryByTitle('Router')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Container mode (proxmox only) ─────────────────────────────────────
|
||||
// ── Container mode ─────────────────────────────────────────────────────
|
||||
|
||||
it('shows Container Mode toggle for proxmox type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
||||
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
|
||||
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
|
||||
|
||||
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Container Mode for non-proxmox types', () => {
|
||||
renderModal({ initial: BASE })
|
||||
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -253,33 +278,48 @@ describe('NodeModal', () => {
|
||||
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({
|
||||
initial: { ...BASE, type: 'vm' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
initial: { ...BASE, type },
|
||||
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('PVE-01')).toBeDefined()
|
||||
expect(screen.getByText('Parent Container')).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({
|
||||
initial: { ...BASE, type: 'lxc' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentContainerNodes: [
|
||||
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
|
||||
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('My Docker Host')).toBeDefined()
|
||||
expect(screen.queryByText('My Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for server type', () => {
|
||||
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'vm' } })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
it('docker_container hides Parent Container when no docker_host is available', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
@@ -327,18 +367,37 @@ describe('NodeModal', () => {
|
||||
|
||||
it('defaults bottom_handles to 1', () => {
|
||||
renderModal({ initial: BASE })
|
||||
expect(selects()[2].value).toBe('1')
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('1')
|
||||
})
|
||||
|
||||
it('pre-fills bottom_handles from initial', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||
expect(selects()[2].value).toBe('3')
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('3')
|
||||
})
|
||||
|
||||
it('submits updated bottom_handles', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
fireEvent.change(selects()[2], { target: { value: '4' } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
fireEvent.change(slider, { target: { value: '12' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||
})
|
||||
|
||||
it('supports the full 1..48 range', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('48')
|
||||
fireEvent.change(slider, { target: { value: '48' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
|
||||
})
|
||||
|
||||
it('clamps pre-filled out-of-range values into [1,48]', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('48')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
@@ -199,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">
|
||||
<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>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -242,7 +243,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -288,7 +289,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@@ -315,10 +316,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)}
|
||||
|
||||
<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
|
||||
</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} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -661,23 +662,87 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||
const portLabel = svc.port != null ? String(svc.port) : 'host'
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : null
|
||||
const inner = (
|
||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
|
||||
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-mono text-[#8b949e]">{portLabel}/{svc.protocol}</span>
|
||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5" title="Remove service"><X size={10} /></button>
|
||||
const hasPort = svc.port != null
|
||||
const portLabel = hasPort ? String(svc.port) : ''
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
|
||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||
>
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium truncate min-w-0 flex-1"
|
||||
style={{ color }}
|
||||
title={svc.service_name}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{svc.service_name}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="font-medium truncate min-w-0 flex-1"
|
||||
style={{ color }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{pathLabel && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="truncate text-[#8b949e] max-w-[80px]"
|
||||
tabIndex={0}
|
||||
aria-label={pathLabel}
|
||||
>
|
||||
{pathLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{pathLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasPort && (
|
||||
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
|
||||
)}
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
|
||||
aria-label="Open service link"
|
||||
style={{ color: 'inherit' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} className="text-muted-foreground" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-2.5 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
|
||||
title="Edit service"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
||||
title="Remove service"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
|
||||
return inner
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
@@ -42,13 +43,19 @@ interface SidebarProps {
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||
const [_collapsed, setCollapsed] = useState(false)
|
||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
|
||||
// When forceView is set, override local state without useEffect
|
||||
const collapsed = forceView ? false : _collapsed
|
||||
const activeView = forceView ?? _activeView
|
||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||
if (forceView !== prevForceView) {
|
||||
setPrevForceView(forceView)
|
||||
if (forceView) {
|
||||
setActiveView(forceView)
|
||||
setCollapsed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||
|
||||
@@ -593,7 +600,7 @@ function ScanHistoryPanel() {
|
||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||
)}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
@@ -749,7 +756,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
|
||||
const btn = (
|
||||
<button
|
||||
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
|
||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
: accent
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
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}
|
||||
disabled={past.length === 0}
|
||||
title="Undo (Ctrl+Z)"
|
||||
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
</Button>
|
||||
<Button
|
||||
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}
|
||||
disabled={future.length === 0}
|
||||
title="Redo (Ctrl+Y)"
|
||||
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<Redo2 size={14} />
|
||||
</Button>
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</Button>
|
||||
<input
|
||||
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
className="hidden"
|
||||
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
|
||||
</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
|
||||
</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
|
||||
</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} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 relative"
|
||||
className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
|
||||
style={{
|
||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||
|
||||
@@ -445,4 +445,46 @@ describe('DetailPanel', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -267,6 +267,18 @@ describe('Sidebar', () => {
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// Regression: forceView used to override local state on every render, freezing
|
||||
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
|
||||
it('allows switching views after forceView is set by parent', async () => {
|
||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
// Parent keeps forceView as 'history'; user clicks another nav item.
|
||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||
fireEvent.click(screen.getByText('Pending Devices'))
|
||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles Settings panel on Settings click', async () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import modalStyles from '../modals/modal-interactive.module.css'
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
@@ -63,13 +64,12 @@ function DialogContent({
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</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"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
<TooltipPrimitive.Content
|
||||
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
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,32 @@ describe('canvasStore', () => {
|
||||
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', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
|
||||
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', () => {
|
||||
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(lxc)
|
||||
useCanvasStore.getState().updateNode('lxc1', { parent_id: undefined })
|
||||
@@ -214,7 +240,7 @@ describe('canvasStore', () => {
|
||||
})
|
||||
|
||||
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().deleteNode('parent')
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
@@ -222,8 +248,8 @@ describe('canvasStore', () => {
|
||||
expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('addNode with parent_id sets parentId and extent', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('parent'))
|
||||
it('addNode with parent_id sets parentId and extent when parent is in container mode', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('parent', { container_mode: true }))
|
||||
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
||||
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'child')
|
||||
expect(child?.parentId).toBe('parent')
|
||||
@@ -422,6 +448,26 @@ describe('canvasStore', () => {
|
||||
expect(hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('setProxmoxContainerMode ON sets width/height for docker_host (not just proxmox)', () => {
|
||||
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: false } }
|
||||
useCanvasStore.setState({ nodes: [host] })
|
||||
useCanvasStore.getState().setProxmoxContainerMode('dh', true)
|
||||
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
|
||||
expect(updated?.data.container_mode).toBe(true)
|
||||
expect(updated?.width).toBe(300)
|
||||
expect(updated?.height).toBe(200)
|
||||
})
|
||||
|
||||
it('setProxmoxContainerMode OFF clears width/height for docker_host', () => {
|
||||
const host: Node<NodeData> = { id: 'dh', type: 'docker_host', position: { x: 0, y: 0 }, width: 300, height: 200, data: { label: 'dh', type: 'docker_host', status: 'unknown', services: [], container_mode: true } }
|
||||
useCanvasStore.setState({ nodes: [host] })
|
||||
useCanvasStore.getState().setProxmoxContainerMode('dh', false)
|
||||
const updated = useCanvasStore.getState().nodes.find((n) => n.id === 'dh')
|
||||
expect(updated?.data.container_mode).toBe(false)
|
||||
expect(updated?.width).toBeUndefined()
|
||||
expect(updated?.height).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setProxmoxContainerMode ON nests children inside proxmox', () => {
|
||||
const proxmox: Node<NodeData> = { id: 'px', type: 'proxmox', position: { x: 0, y: 0 }, data: { label: 'px', type: 'proxmox', status: 'unknown', services: [], container_mode: false } }
|
||||
const child = makeNode('vm1', { parent_id: 'px', type: 'vm' })
|
||||
@@ -675,4 +721,117 @@ describe('canvasStore', () => {
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(updated?.sourceHandle).toBe('bottom')
|
||||
})
|
||||
|
||||
// Regression: handle cap raised from 4 to 48 — remap must scale.
|
||||
it('remaps high-count handles when shrinking from 12 to 2', () => {
|
||||
const node = makeNode('n1', { bottom_handles: 12 })
|
||||
const edges = [
|
||||
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' },
|
||||
{ ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' },
|
||||
{ ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' },
|
||||
]
|
||||
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
|
||||
|
||||
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
|
||||
|
||||
const after = useCanvasStore.getState().edges
|
||||
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2')
|
||||
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
|
||||
expect(after.find((e) => e.id === 'e12')?.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 { useThemeStore } from '@/stores/themeStore'
|
||||
import type { CustomStyleDef } from '@/types'
|
||||
|
||||
describe('themeStore', () => {
|
||||
beforeEach(() => {
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
useThemeStore.setState({ activeTheme: 'default', customStyle: { nodes: {}, edges: {} } })
|
||||
})
|
||||
|
||||
it('starts with default theme', () => {
|
||||
@@ -15,8 +16,8 @@ describe('themeStore', () => {
|
||||
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||
})
|
||||
|
||||
it('setTheme can switch between all presets', () => {
|
||||
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
|
||||
it('setTheme can switch between all presets including custom', () => {
|
||||
const themes = ['default', 'dark', 'light', 'neon', 'matrix', 'custom'] as const
|
||||
for (const id of themes) {
|
||||
useThemeStore.getState().setTheme(id)
|
||||
expect(useThemeStore.getState().activeTheme).toBe(id)
|
||||
@@ -28,4 +29,26 @@ describe('themeStore', () => {
|
||||
useThemeStore.getState().setTheme('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,
|
||||
addEdge,
|
||||
} 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 { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
|
||||
@@ -58,6 +59,9 @@ interface CanvasState {
|
||||
notifyScanDeviceFound: () => void
|
||||
hideIp: boolean
|
||||
toggleHideIp: () => void
|
||||
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
@@ -172,8 +176,18 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
addNode: (node) =>
|
||||
set((state) => {
|
||||
const enriched = node.data.parent_id
|
||||
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
|
||||
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
|
||||
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
|
||||
// Parents must come before children in the array (React Flow requirement)
|
||||
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||
@@ -283,14 +297,37 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
setProxmoxContainerMode: (proxmoxId, enabled) =>
|
||||
set((state) => {
|
||||
const parentNode = state.nodes.find((n) => n.id === proxmoxId)
|
||||
let nodes = state.nodes.map((n) => {
|
||||
if (n.id === proxmoxId) {
|
||||
const withMode = { ...n, data: { ...n.data, container_mode: enabled } }
|
||||
return enabled
|
||||
? { ...withMode, width: 300, height: 200 }
|
||||
? { ...withMode, width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: { ...withMode, width: undefined, height: undefined }
|
||||
}
|
||||
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
|
||||
? { ...n, parentId: proxmoxId, extent: 'parent' as const }
|
||||
: { ...n, parentId: undefined, extent: undefined }
|
||||
@@ -435,4 +472,82 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
},
|
||||
|
||||
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 type { ThemeId } from '@/utils/themes'
|
||||
import type { CustomStyleDef } from '@/types'
|
||||
|
||||
interface ThemeState {
|
||||
activeTheme: ThemeId
|
||||
setTheme: (id: ThemeId) => void
|
||||
customStyle: CustomStyleDef
|
||||
setCustomStyle: (def: CustomStyleDef) => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>((set) => ({
|
||||
activeTheme: 'default',
|
||||
setTheme: (id) => set({ activeTheme: id }),
|
||||
customStyle: { nodes: {}, edges: {} },
|
||||
setCustomStyle: (def) => set({ customStyle: def }),
|
||||
}))
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
|
||||
|
||||
describe('NODE_TYPE_LABELS', () => {
|
||||
it('has an entry for every node type', () => {
|
||||
const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||
expectedTypes.forEach((t) => {
|
||||
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
||||
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type NodeType =
|
||||
| 'isp'
|
||||
| 'router'
|
||||
| 'firewall'
|
||||
| 'switch'
|
||||
| 'server'
|
||||
| 'proxmox'
|
||||
@@ -13,7 +14,8 @@ export type NodeType =
|
||||
| 'printer'
|
||||
| 'computer'
|
||||
| 'cpl'
|
||||
| 'docker'
|
||||
| 'docker_host'
|
||||
| 'docker_container'
|
||||
| 'generic'
|
||||
| 'groupRect'
|
||||
| 'group'
|
||||
@@ -91,6 +93,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
height?: number
|
||||
}
|
||||
custom_icon?: string
|
||||
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||
bottom_handles?: number
|
||||
}
|
||||
|
||||
@@ -115,6 +118,7 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
isp: 'ISP / Modem',
|
||||
router: 'Router',
|
||||
firewall: 'Firewall',
|
||||
switch: 'Switch',
|
||||
server: 'Server',
|
||||
proxmox: 'Proxmox VE',
|
||||
@@ -127,7 +131,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
printer: 'Printer',
|
||||
computer: 'Computer',
|
||||
cpl: 'CPL / Powerline',
|
||||
docker: 'Docker Host',
|
||||
docker_host: 'Docker Host',
|
||||
docker_container: 'Docker Container',
|
||||
generic: 'Generic Device',
|
||||
groupRect: 'Group Rectangle',
|
||||
group: 'Node Group',
|
||||
@@ -148,3 +153,26 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
||||
virtual: 'Virtual',
|
||||
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>>
|
||||
}
|
||||
|
||||
@@ -1,51 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
BOTTOM_HANDLE_IDS,
|
||||
BOTTOM_HANDLE_POSITIONS,
|
||||
MIN_BOTTOM_HANDLES,
|
||||
MAX_BOTTOM_HANDLES,
|
||||
bottomHandleId,
|
||||
bottomHandlePositions,
|
||||
clampBottomHandles,
|
||||
normalizeHandle,
|
||||
removedBottomHandleIds,
|
||||
} from '../handleUtils'
|
||||
|
||||
describe('BOTTOM_HANDLE_IDS', () => {
|
||||
describe('bottomHandleId', () => {
|
||||
it('first id is always "bottom" for backward compatibility', () => {
|
||||
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
|
||||
expect(bottomHandleId(0)).toBe('bottom')
|
||||
})
|
||||
|
||||
it('has ids for 1–4 handles', () => {
|
||||
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
||||
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
||||
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
|
||||
expect(bottomHandleId(1)).toBe('bottom-2')
|
||||
expect(bottomHandleId(2)).toBe('bottom-3')
|
||||
expect(bottomHandleId(3)).toBe('bottom-4')
|
||||
expect(bottomHandleId(11)).toBe('bottom-12')
|
||||
expect(bottomHandleId(47)).toBe('bottom-48')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BOTTOM_HANDLE_POSITIONS', () => {
|
||||
describe('clampBottomHandles', () => {
|
||||
it('clamps below MIN to MIN', () => {
|
||||
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('clamps above MAX to MAX', () => {
|
||||
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('returns MIN for non-finite or non-number', () => {
|
||||
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('floors fractional values', () => {
|
||||
expect(clampBottomHandles(3.9)).toBe(3)
|
||||
})
|
||||
|
||||
it('passes valid integers through', () => {
|
||||
expect(clampBottomHandles(1)).toBe(1)
|
||||
expect(clampBottomHandles(24)).toBe(24)
|
||||
expect(clampBottomHandles(48)).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bottomHandlePositions — backward-compat lock for 1..4', () => {
|
||||
// These exact arrays are the pre-multi-handle hand-tuned positions.
|
||||
// Existing user canvases depend on them — do NOT change.
|
||||
it('1 handle is centered at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
||||
expect(bottomHandlePositions(1)).toEqual([50])
|
||||
})
|
||||
|
||||
it('2 handles are symmetric', () => {
|
||||
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
||||
expect(a).toBeLessThan(50)
|
||||
expect(b).toBeGreaterThan(50)
|
||||
expect(a + b).toBe(100)
|
||||
it('2 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(2)).toEqual([25, 75])
|
||||
})
|
||||
|
||||
it('3 handles include a center at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
||||
it('3 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
|
||||
})
|
||||
|
||||
it('4 handles are evenly spaced', () => {
|
||||
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
||||
expect(pos).toHaveLength(4)
|
||||
// All values should be between 0 and 100 exclusive
|
||||
it('4 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
|
||||
})
|
||||
})
|
||||
|
||||
describe('bottomHandlePositions — uniform spacing for ≥5', () => {
|
||||
it('returns count entries', () => {
|
||||
expect(bottomHandlePositions(5)).toHaveLength(5)
|
||||
expect(bottomHandlePositions(12)).toHaveLength(12)
|
||||
expect(bottomHandlePositions(48)).toHaveLength(48)
|
||||
})
|
||||
|
||||
it('all positions strictly between 0 and 100', () => {
|
||||
const pos = bottomHandlePositions(48)
|
||||
pos.forEach((p) => {
|
||||
expect(p).toBeGreaterThan(0)
|
||||
expect(p).toBeLessThan(100)
|
||||
})
|
||||
// Positions should be strictly increasing
|
||||
})
|
||||
|
||||
it('positions are strictly increasing and uniform', () => {
|
||||
const pos = bottomHandlePositions(12)
|
||||
for (let i = 1; i < pos.length; i++) {
|
||||
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
||||
}
|
||||
const step = 100 / 13
|
||||
expect(pos[0]).toBeCloseTo(step, 5)
|
||||
expect(pos[11]).toBeCloseTo(step * 12, 5)
|
||||
})
|
||||
|
||||
it('clamps out-of-range counts before computing', () => {
|
||||
expect(bottomHandlePositions(0)).toEqual([50])
|
||||
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,16 +117,10 @@ describe('normalizeHandle', () => {
|
||||
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
||||
})
|
||||
|
||||
it('maps bottom-2-t → bottom-2', () => {
|
||||
it('maps bottom-N-t → bottom-N for any N', () => {
|
||||
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
||||
})
|
||||
|
||||
it('maps bottom-3-t → bottom-3', () => {
|
||||
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
|
||||
})
|
||||
|
||||
it('maps bottom-4-t → bottom-4', () => {
|
||||
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
|
||||
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
|
||||
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
|
||||
})
|
||||
|
||||
it('passes through non-stub handles unchanged', () => {
|
||||
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
|
||||
})
|
||||
|
||||
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('3 → 2 removes only bottom-3', () => {
|
||||
const removed = removedBottomHandleIds(3, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3']))
|
||||
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
|
||||
})
|
||||
|
||||
it('never removes "bottom" (index 0)', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false)
|
||||
})
|
||||
|
||||
// Regression: scaling the cap from 4 → 48 must not break the remap loop.
|
||||
it('scales to high counts (48 → 1 removes 47 ids)', () => {
|
||||
const removed = removedBottomHandleIds(48, 1)
|
||||
expect(removed.size).toBe(47)
|
||||
expect(removed.has('bottom-2')).toBe(true)
|
||||
expect(removed.has('bottom-48')).toBe(true)
|
||||
expect(removed.has('bottom')).toBe(false)
|
||||
})
|
||||
|
||||
it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => {
|
||||
const removed = removedBottomHandleIds(10, 2)
|
||||
expect(removed.size).toBe(8)
|
||||
expect(removed.has('bottom-2')).toBe(false)
|
||||
expect(removed.has('bottom-3')).toBe(true)
|
||||
expect(removed.has('bottom-10')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,8 +3,8 @@ import { THEMES, THEME_ORDER, type ThemeId } from '../themes'
|
||||
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||
|
||||
const NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
|
||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||
]
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||
@@ -84,7 +84,7 @@ describe('THEMES', () => {
|
||||
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
||||
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
||||
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.nodeIconBackground).toBe('#161b22')
|
||||
expect(d.canvasBackground).toBe('#0d1117')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||
import { normalizeHandle } from '@/utils/handleUtils'
|
||||
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.measured?.width ?? n.width ?? null,
|
||||
height: n.measured?.height ?? n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
@@ -134,6 +134,7 @@ export function deserializeApiNode(
|
||||
n: ApiNode,
|
||||
proxmoxContainerMap: Map<string, boolean>,
|
||||
): Node<NodeData> {
|
||||
const normalizedType = n.type === 'docker' ? 'docker_host' : n.type
|
||||
if (n.type === 'groupRect') {
|
||||
const w = (n.custom_colors?.width as number | undefined) ?? 360
|
||||
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
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
type: normalizedType,
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n as unknown as NodeData,
|
||||
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
|
||||
...(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 } : {}),
|
||||
...(n.height ? { height: n.height } : n.type === 'proxmox' && n.container_mode !== false ? { height: 200 } : {}),
|
||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: {}),
|
||||
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}),
|
||||
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,3 +27,17 @@ export function rgbaToHex8(hex6: string, alpha: number): string {
|
||||
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})`
|
||||
}
|
||||
|
||||
@@ -2,20 +2,44 @@
|
||||
* Bottom handle configuration for multi-handle nodes.
|
||||
*
|
||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
||||
*
|
||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
||||
*/
|
||||
|
||||
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
|
||||
export const MIN_BOTTOM_HANDLES = 1
|
||||
export const MAX_BOTTOM_HANDLES = 48
|
||||
|
||||
/** Left % position for each handle slot, per count. */
|
||||
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
||||
1: [50],
|
||||
2: [25, 75],
|
||||
3: [20, 50, 80],
|
||||
4: [15, 38, 62, 85],
|
||||
/** Returns the source handle ID at a given slot index. */
|
||||
export function bottomHandleId(idx: number): string {
|
||||
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
||||
}
|
||||
|
||||
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
|
||||
export function clampBottomHandles(n: unknown): number {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
|
||||
const i = Math.floor(n)
|
||||
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
|
||||
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
|
||||
return i
|
||||
}
|
||||
|
||||
/**
|
||||
* Left % positions for each handle slot.
|
||||
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
|
||||
* positions on canvases saved before the multi-handle expansion.
|
||||
* Counts ≥ 5 use uniform spacing.
|
||||
*/
|
||||
export function bottomHandlePositions(count: number): number[] {
|
||||
const c = clampBottomHandles(count)
|
||||
switch (c) {
|
||||
case 1: return [50]
|
||||
case 2: return [25, 75]
|
||||
case 3: return [20, 50, 80]
|
||||
case 4: return [15, 38, 62, 85]
|
||||
default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null {
|
||||
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||
const removed = new Set<string>()
|
||||
for (let i = newCount; i < oldCount; i++) {
|
||||
removed.add(BOTTOM_HANDLE_IDS[i])
|
||||
removed.add(bottomHandleId(i))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
// Storage & Databases
|
||||
Database, Archive, Cloud, FolderOpen,
|
||||
// Security & Auth
|
||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck,
|
||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||
// Automation & IoT
|
||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
||||
// Transfers & sync
|
||||
@@ -32,21 +32,22 @@ export interface IconEntry {
|
||||
|
||||
export const ICON_REGISTRY: IconEntry[] = [
|
||||
// --- Infrastructure ---
|
||||
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
||||
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
||||
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
||||
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
||||
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
||||
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
||||
{ key: 'flame', label: 'Firewall', category: 'Infrastructure', icon: Flame },
|
||||
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
||||
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
||||
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
|
||||
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
|
||||
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
|
||||
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
|
||||
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
|
||||
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
|
||||
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
|
||||
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
|
||||
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
|
||||
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
|
||||
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
|
||||
{ key: 'box', label: 'VM', category: 'Infrastructure', icon: Box },
|
||||
{ key: 'container', label: 'Container / LXC', category: 'Infrastructure', icon: Container },
|
||||
{ key: 'harddrive', label: 'NAS / Storage', category: 'Infrastructure', icon: HardDrive },
|
||||
{ key: 'cpu', label: 'IoT / Embedded', category: 'Infrastructure', icon: Cpu },
|
||||
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
|
||||
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
|
||||
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
|
||||
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
|
||||
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
|
||||
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
|
||||
|
||||
// --- Media ---
|
||||
{ key: 'play', label: 'Jellyfin / Emby', category: 'Media', icon: Play },
|
||||
@@ -62,44 +63,44 @@ export const ICON_REGISTRY: IconEntry[] = [
|
||||
{ key: 'cctv', label: 'CCTV / IP Camera', category: 'Media', icon: Cctv },
|
||||
|
||||
// --- Monitoring ---
|
||||
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
|
||||
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
|
||||
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
|
||||
{ key: 'activity', label: 'Prometheus / Uptime', category: 'Monitoring', icon: Activity },
|
||||
{ key: 'barchart', label: 'Grafana / Kibana', category: 'Monitoring', icon: BarChart2 },
|
||||
{ key: 'linechart', label: 'InfluxDB / Metrics', category: 'Monitoring', icon: LineChart },
|
||||
{ key: 'eye', label: 'Overseerr / Watchlist', category: 'Monitoring', icon: Eye },
|
||||
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
|
||||
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
|
||||
{ key: 'bell', label: 'Alerts / Notifiarr', category: 'Monitoring', icon: Bell },
|
||||
{ key: 'gauge', label: 'Dashboard / Status', category: 'Monitoring', icon: Gauge },
|
||||
|
||||
// --- Storage & Databases ---
|
||||
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
|
||||
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
|
||||
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
|
||||
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
|
||||
{ key: 'database', label: 'Database (SQL/NoSQL)', category: 'Storage', icon: Database },
|
||||
{ key: 'archive', label: 'Backup / Archive', category: 'Storage', icon: Archive },
|
||||
{ key: 'cloud', label: 'Nextcloud / S3', category: 'Storage', icon: Cloud },
|
||||
{ key: 'folder', label: 'Files / Filebrowser', category: 'Storage', icon: FolderOpen },
|
||||
{ key: 'download', label: 'Downloader (Torrent/NZB)', category: 'Storage', icon: Download },
|
||||
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
|
||||
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
|
||||
{ key: 'upload', label: 'Upload / Sync', category: 'Storage', icon: Upload },
|
||||
{ key: 'refresh', label: 'Sync / Resilio', category: 'Storage', icon: RefreshCw },
|
||||
|
||||
// --- Security & Auth ---
|
||||
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
|
||||
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
|
||||
{ key: 'shield', label: 'Pi-hole / DNS Block', category: 'Security', icon: Shield },
|
||||
{ key: 'shieldcheck', label: 'AdGuard Home', category: 'Security', icon: ShieldCheck },
|
||||
{ key: 'lock', label: 'Authelia / Authentik', category: 'Security', icon: Lock },
|
||||
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
|
||||
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
|
||||
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
|
||||
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
|
||||
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
|
||||
{ key: 'key', label: 'Vaultwarden / Vault', category: 'Security', icon: Key },
|
||||
{ key: 'users', label: 'LDAP / SSO', category: 'Security', icon: Users },
|
||||
{ key: 'usercheck', label: 'Keycloak', category: 'Security', icon: UserCheck },
|
||||
{ key: 'filter', label: 'Prowlarr / Jackett', category: 'Security', icon: Filter },
|
||||
{ key: 'search', label: 'Search / Indexer', category: 'Security', icon: Search },
|
||||
|
||||
// --- Automation & Smart Home ---
|
||||
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
|
||||
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
|
||||
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
|
||||
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
|
||||
{ key: 'home', label: 'Home Assistant', category: 'Automation', icon: Home },
|
||||
{ key: 'zap', label: 'ESPHome / Node-RED', category: 'Automation', icon: Zap },
|
||||
{ key: 'workflow', label: 'n8n / Node-RED', category: 'Automation', icon: Workflow },
|
||||
{ key: 'bot', label: 'Bot / Automation', category: 'Automation', icon: Bot },
|
||||
{ key: 'thermometer', label: 'Sensor / Temperature', category: 'Automation', icon: Thermometer },
|
||||
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
|
||||
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
|
||||
{ key: 'lightbulb', label: 'Smart Light / Zigbee', category: 'Automation', icon: Lightbulb },
|
||||
{ key: 'radio', label: 'MQTT / RTL-SDR', category: 'Automation', icon: Radio },
|
||||
|
||||
// --- Containers & Dev ---
|
||||
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
||||
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
|
||||
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
||||
{ key: 'package', label: 'Docker Host / Container', category: 'Dev & Containers', icon: Package },
|
||||
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
|
||||
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
||||
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
|
||||
@@ -118,24 +119,26 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
)
|
||||
|
||||
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
cpl: PlugZap,
|
||||
docker: Anchor,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
firewall: Flame,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
cpl: PlugZap,
|
||||
docker_host: Anchor,
|
||||
docker_container: Package,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
}
|
||||
|
||||
/** Resolve the display icon for a node — custom_icon takes priority over type default. */
|
||||
|
||||
+159
-92
@@ -1,6 +1,6 @@
|
||||
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 {
|
||||
// Per node-type accent (border + icon)
|
||||
@@ -42,24 +42,26 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
description: 'Dark futuristic — the original Homelable look',
|
||||
colors: {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
switch: { border: '#39d353', icon: '#39d353' },
|
||||
server: { border: '#a855f7', icon: '#a855f7' },
|
||||
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
vm: { border: '#a855f7', icon: '#a855f7' },
|
||||
lxc: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
nas: { border: '#39d353', icon: '#39d353' },
|
||||
iot: { border: '#e3b341', icon: '#e3b341' },
|
||||
ap: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
camera: { border: '#8b949e', icon: '#8b949e' },
|
||||
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
firewall: { border: '#f85149', icon: '#f85149' },
|
||||
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',
|
||||
@@ -97,24 +99,26 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
description: 'Pure black with maximum contrast',
|
||||
colors: {
|
||||
nodeAccents: {
|
||||
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
router: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
switch: { border: '#4ade80', icon: '#4ade80' },
|
||||
server: { border: '#c084fc', icon: '#c084fc' },
|
||||
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
||||
vm: { border: '#c084fc', icon: '#c084fc' },
|
||||
lxc: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
nas: { border: '#4ade80', icon: '#4ade80' },
|
||||
iot: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
ap: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
camera: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
router: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
firewall: { border: '#ef4444', icon: '#ef4444' },
|
||||
switch: { border: '#4ade80', icon: '#4ade80' },
|
||||
server: { border: '#c084fc', icon: '#c084fc' },
|
||||
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
||||
vm: { border: '#c084fc', icon: '#c084fc' },
|
||||
lxc: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
nas: { border: '#4ade80', icon: '#4ade80' },
|
||||
iot: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
ap: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
camera: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
|
||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
},
|
||||
nodeCardBackground: '#0a0a0a',
|
||||
nodeIconBackground: '#111111',
|
||||
@@ -152,24 +156,26 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
description: 'Clean light theme with dark text',
|
||||
colors: {
|
||||
nodeAccents: {
|
||||
isp: { border: '#0284c7', icon: '#0284c7' },
|
||||
router: { border: '#0284c7', icon: '#0284c7' },
|
||||
switch: { border: '#16a34a', icon: '#16a34a' },
|
||||
server: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
||||
vm: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
lxc: { border: '#0284c7', icon: '#0284c7' },
|
||||
nas: { border: '#16a34a', icon: '#16a34a' },
|
||||
iot: { border: '#b45309', icon: '#b45309' },
|
||||
ap: { border: '#0284c7', icon: '#0284c7' },
|
||||
camera: { border: '#6b7280', icon: '#6b7280' },
|
||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
cpl: { border: '#b45309', icon: '#b45309' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
||||
group: { border: '#0284c7', icon: '#0284c7' },
|
||||
isp: { border: '#0284c7', icon: '#0284c7' },
|
||||
router: { border: '#0284c7', icon: '#0284c7' },
|
||||
firewall: { border: '#dc2626', icon: '#dc2626' },
|
||||
switch: { border: '#16a34a', icon: '#16a34a' },
|
||||
server: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
||||
vm: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
lxc: { border: '#0284c7', icon: '#0284c7' },
|
||||
nas: { border: '#16a34a', icon: '#16a34a' },
|
||||
iot: { border: '#b45309', icon: '#b45309' },
|
||||
ap: { border: '#0284c7', icon: '#0284c7' },
|
||||
camera: { border: '#6b7280', icon: '#6b7280' },
|
||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
cpl: { border: '#b45309', icon: '#b45309' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#0369a1', icon: '#0369a1' },
|
||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||
group: { border: '#0284c7', icon: '#0284c7' },
|
||||
},
|
||||
nodeCardBackground: '#ffffff',
|
||||
nodeIconBackground: '#f0f6ff',
|
||||
@@ -207,24 +213,26 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
description: 'Cyberpunk vibes with vivid glowing accents',
|
||||
colors: {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00ffff', icon: '#00ffff' },
|
||||
router: { border: '#00ffff', icon: '#00ffff' },
|
||||
switch: { border: '#00ff80', icon: '#00ff80' },
|
||||
server: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
||||
vm: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
lxc: { border: '#00ffff', icon: '#00ffff' },
|
||||
nas: { border: '#00ff80', icon: '#00ff80' },
|
||||
iot: { border: '#ffff00', icon: '#ffff00' },
|
||||
ap: { border: '#00ffff', icon: '#00ffff' },
|
||||
camera: { border: '#8888ff', icon: '#8888ff' },
|
||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||
docker: { border: '#00aaff', icon: '#00aaff' },
|
||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
||||
group: { border: '#00ffff', icon: '#00ffff' },
|
||||
isp: { border: '#00ffff', icon: '#00ffff' },
|
||||
router: { border: '#00ffff', icon: '#00ffff' },
|
||||
firewall: { border: '#ff0040', icon: '#ff0040' },
|
||||
switch: { border: '#00ff80', icon: '#00ff80' },
|
||||
server: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
||||
vm: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
lxc: { border: '#00ffff', icon: '#00ffff' },
|
||||
nas: { border: '#00ff80', icon: '#00ff80' },
|
||||
iot: { border: '#ffff00', icon: '#ffff00' },
|
||||
ap: { border: '#00ffff', icon: '#00ffff' },
|
||||
camera: { border: '#8888ff', icon: '#8888ff' },
|
||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||
docker_host: { border: '#00aaff', icon: '#00aaff' },
|
||||
docker_container: { border: '#00ddff', icon: '#00ddff' },
|
||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||
group: { border: '#00ffff', icon: '#00ffff' },
|
||||
},
|
||||
nodeCardBackground: '#0f0f2a',
|
||||
nodeIconBackground: '#0a0a1a',
|
||||
@@ -262,24 +270,26 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
description: 'Everything in terminal green',
|
||||
colors: {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00ff41', icon: '#00ff41' },
|
||||
router: { border: '#00ff41', icon: '#00ff41' },
|
||||
switch: { border: '#00cc33', icon: '#00cc33' },
|
||||
server: { border: '#008822', icon: '#008822' },
|
||||
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
||||
vm: { border: '#008822', icon: '#008822' },
|
||||
lxc: { border: '#00ff41', icon: '#00ff41' },
|
||||
nas: { border: '#00cc33', icon: '#00cc33' },
|
||||
iot: { border: '#66ff33', icon: '#66ff33' },
|
||||
ap: { border: '#00ff41', icon: '#00ff41' },
|
||||
camera: { border: '#005500', icon: '#005500' },
|
||||
printer: { border: '#005500', icon: '#005500' },
|
||||
computer: { border: '#008822', icon: '#008822' },
|
||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||
docker: { border: '#00cc88', icon: '#00cc88' },
|
||||
generic: { border: '#006600', icon: '#006600' },
|
||||
groupRect:{ border: '#00ff41', icon: '#00ff41' },
|
||||
group: { border: '#00ff41', icon: '#00ff41' },
|
||||
isp: { border: '#00ff41', icon: '#00ff41' },
|
||||
router: { border: '#00ff41', icon: '#00ff41' },
|
||||
firewall: { border: '#88ff00', icon: '#88ff00' },
|
||||
switch: { border: '#00cc33', icon: '#00cc33' },
|
||||
server: { border: '#008822', icon: '#008822' },
|
||||
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
||||
vm: { border: '#008822', icon: '#008822' },
|
||||
lxc: { border: '#00ff41', icon: '#00ff41' },
|
||||
nas: { border: '#00cc33', icon: '#00cc33' },
|
||||
iot: { border: '#66ff33', icon: '#66ff33' },
|
||||
ap: { border: '#00ff41', icon: '#00ff41' },
|
||||
camera: { border: '#005500', icon: '#005500' },
|
||||
printer: { border: '#005500', icon: '#005500' },
|
||||
computer: { border: '#008822', icon: '#008822' },
|
||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||
docker_host: { border: '#00cc88', icon: '#00cc88' },
|
||||
docker_container: { border: '#00aacc', icon: '#00aacc' },
|
||||
generic: { border: '#006600', icon: '#006600' },
|
||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||
group: { border: '#00ff41', icon: '#00ff41' },
|
||||
},
|
||||
nodeCardBackground: '#001100',
|
||||
nodeIconBackground: '#002200',
|
||||
@@ -310,7 +320,64 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
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' },
|
||||
firewall: { border: '#f85149', icon: '#f85149' },
|
||||
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
|
||||
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 fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
from mcp.server import Server
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from starlette.routing import Mount
|
||||
|
||||
from .auth import ApiKeyMiddleware
|
||||
from .backend_client import backend
|
||||
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
|
||||
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.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")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
Reference in New Issue
Block a user