Compare commits
20 Commits
feat/liveview
...
v1.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 49963c79f7 | |||
| ea539d6e31 | |||
| f657e45995 | |||
| 3fe9fa7ca8 | |||
| f0222247bb | |||
| 9c92d39629 | |||
| e4c0d820f4 | |||
| f9c8e37de3 | |||
| 7ed6b77165 | |||
| 95a3db34f1 | |||
| 37cb97dca1 | |||
| 32b60a201b | |||
| 4ccdbed711 | |||
| 38a06682e5 | |||
| 900cc62b27 | |||
| 343249fbcd | |||
| 4aca82fb1a | |||
| bd047e594e | |||
| 61b30a95fe | |||
| 0b97b7127a |
@@ -1,6 +1,7 @@
|
||||
# Backend - server-side only (NEVER commit .env)
|
||||
SECRET_KEY=change_me_in_production
|
||||
SQLITE_PATH=./data/homelab.db
|
||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
# Auth — default credentials: admin / admin
|
||||
|
||||
@@ -16,8 +16,8 @@ If you just like the design, you can only run the frontend and export your desig
|
||||
<p align="center">
|
||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
|
||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
|
||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
|
||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
@@ -19,6 +23,17 @@ class Settings(BaseSettings):
|
||||
auth_username: str = "admin"
|
||||
auth_password_hash: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_password_hash(self) -> "Settings":
|
||||
h = self.auth_password_hash
|
||||
if h and not h.startswith("$2"):
|
||||
logger.error(
|
||||
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
|
||||
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
||||
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
||||
)
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bool(pwd_context.verify(plain, hashed))
|
||||
try:
|
||||
return bool(pwd_context.verify(plain, hashed))
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
|
||||
+22
-15
@@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -26,36 +27,42 @@ async def init_db() -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns introduced after initial schema (idempotent)
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||
with suppress(Exception):
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||
with suppress(OperationalError):
|
||||
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
|
||||
await conn.exec_driver_sql(sql)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
@@ -33,7 +33,7 @@ class Node(Base):
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
|
||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
@@ -65,7 +65,7 @@ class Edge(Base):
|
||||
speed: Mapped[str | None] = mapped_column(String)
|
||||
custom_color: Mapped[str | None] = mapped_column(String)
|
||||
path_style: Mapped[str | None] = mapped_column(String)
|
||||
animated: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||
source_handle: Mapped[str | None] = mapped_column(String)
|
||||
target_handle: Mapped[str | None] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app = FastAPI(
|
||||
title="Homelable API",
|
||||
version="1.3.3",
|
||||
version="1.4.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.schemas.utils import normalize_animated
|
||||
|
||||
|
||||
class NodeSave(BaseModel):
|
||||
@@ -44,10 +45,15 @@ class EdgeSave(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
animated: bool = False
|
||||
animated: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
def validate_animated(cls, v: object) -> str:
|
||||
return normalize_animated(v)
|
||||
|
||||
|
||||
class CanvasSaveRequest(BaseModel):
|
||||
nodes: list[NodeSave] = []
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.schemas.utils import normalize_animated
|
||||
|
||||
|
||||
class EdgeBase(BaseModel):
|
||||
@@ -12,10 +14,15 @@ class EdgeBase(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
animated: bool = False
|
||||
animated: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
def validate_animated(cls, v: object) -> str:
|
||||
return normalize_animated(v)
|
||||
|
||||
|
||||
class EdgeCreate(EdgeBase):
|
||||
pass
|
||||
@@ -28,10 +35,17 @@ class EdgeUpdate(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
animated: bool | None = None
|
||||
animated: str | None = None
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
def validate_animated(cls, v: object) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
return normalize_animated(v)
|
||||
|
||||
|
||||
class EdgeResponse(EdgeBase):
|
||||
id: str
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
def normalize_animated(v: object) -> str:
|
||||
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
|
||||
if v is True or v == 1 or v == '1':
|
||||
return 'snake'
|
||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
||||
return 'none'
|
||||
if v in ('snake', 'flow'):
|
||||
return str(v)
|
||||
return 'none'
|
||||
@@ -56,3 +56,15 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
||||
settings.mcp_service_key = ""
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
|
||||
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
|
||||
from app.core.config import settings
|
||||
original = settings.auth_password_hash
|
||||
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
|
||||
try:
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
settings.auth_password_hash = original
|
||||
|
||||
@@ -104,6 +104,25 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
|
||||
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
|
||||
|
||||
|
||||
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
|
||||
"""label_position and text_size are stored in custom_colors and returned unchanged."""
|
||||
n1 = node_payload(custom_colors={
|
||||
"border": "#00d4ff",
|
||||
"border_style": "solid",
|
||||
"border_width": 3,
|
||||
"label_position": "outside",
|
||||
"text_size": 16,
|
||||
"text_color": "#e6edf3",
|
||||
})
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
cc = canvas["nodes"][0]["custom_colors"]
|
||||
assert cc["label_position"] == "outside"
|
||||
assert cc["text_size"] == 16
|
||||
assert cc["border_width"] == 3
|
||||
|
||||
|
||||
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
|
||||
+1
-2
@@ -7,9 +7,8 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override env_file values that differ in Docker
|
||||
# Override env_file: SQLite path must point inside the container volume
|
||||
SQLITE_PATH: /app/data/homelab.db
|
||||
CORS_ORIGINS: '["http://localhost:3000"]'
|
||||
volumes:
|
||||
- backend_data:/app/data
|
||||
networks:
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.3.3",
|
||||
"version": "1.4.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.3.3",
|
||||
"version": "1.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+13
-3
@@ -182,9 +182,12 @@ export default function App() {
|
||||
custom_colors: {
|
||||
border: data.border_color,
|
||||
border_style: data.border_style,
|
||||
border_width: data.border_width,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_position: data.text_position,
|
||||
text_size: data.text_size,
|
||||
label_position: data.label_position,
|
||||
font: data.font,
|
||||
z_order: data.z_order,
|
||||
},
|
||||
@@ -198,6 +201,7 @@ export default function App() {
|
||||
|
||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
if (!editingGroupRectId) return
|
||||
snapshotHistory()
|
||||
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
||||
updateNode(editingGroupRectId, {
|
||||
label: data.label,
|
||||
@@ -205,16 +209,19 @@ export default function App() {
|
||||
...existing?.data.custom_colors,
|
||||
border: data.border_color,
|
||||
border_style: data.border_style,
|
||||
border_width: data.border_width,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_position: data.text_position,
|
||||
text_size: data.text_size,
|
||||
label_position: data.label_position,
|
||||
font: data.font,
|
||||
z_order: data.z_order,
|
||||
},
|
||||
})
|
||||
setNodeZIndex(editingGroupRectId, data.z_order - 10)
|
||||
setEditingGroupRectId(null)
|
||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
|
||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
||||
|
||||
const handleDeleteGroupRect = useCallback(() => {
|
||||
if (!editingGroupRectId) return
|
||||
@@ -436,7 +443,7 @@ export default function App() {
|
||||
open={addGroupRectOpen}
|
||||
onClose={() => setAddGroupRectOpen(false)}
|
||||
onSubmit={handleAddGroupRect}
|
||||
title="Add Rectangle"
|
||||
title="Add Zone"
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different rect */}
|
||||
@@ -457,11 +464,14 @@ export default function App() {
|
||||
text_position: rc.text_position ?? 'top-left',
|
||||
border_color: rc.border ?? '#00d4ff',
|
||||
border_style: rc.border_style ?? 'solid',
|
||||
border_width: rc.border_width ?? 2,
|
||||
background_color: rc.background ?? '#00d4ff0d',
|
||||
text_size: rc.text_size ?? 12,
|
||||
label_position: rc.label_position ?? 'inside',
|
||||
z_order: rc.z_order ?? 1,
|
||||
}
|
||||
})()}
|
||||
title="Edit Rectangle"
|
||||
title="Edit Zone"
|
||||
/>
|
||||
|
||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||
|
||||
@@ -20,8 +20,9 @@ export function LoginPage() {
|
||||
try {
|
||||
const res = await authApi.login(username, password)
|
||||
login(res.data.access_token)
|
||||
} catch {
|
||||
setError('Invalid username or password')
|
||||
} catch (err: unknown) {
|
||||
const hasResponse = err && typeof err === 'object' && 'response' in err
|
||||
setError(hasResponse ? 'Invalid username or password' : 'Could not reach the server — check your CORS_ORIGINS setting')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export function LoginPage() {
|
||||
</form>
|
||||
|
||||
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
|
||||
Credentials configured in <span className="font-mono">config.yml</span>
|
||||
Credentials configured in <span className="font-mono">.env</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('LoginPage', () => {
|
||||
})
|
||||
|
||||
it('shows a generic error message — no credential enumeration', async () => {
|
||||
vi.mocked(authApi.login).mockRejectedValue(new Error('401'))
|
||||
vi.mocked(authApi.login).mockRejectedValue({ response: { status: 401 } })
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'wrongpass' } })
|
||||
@@ -65,10 +65,21 @@ describe('LoginPage', () => {
|
||||
expect(errors[0].textContent).toBe('Invalid username or password')
|
||||
})
|
||||
|
||||
it('shows a network error message when no response (e.g. CORS misconfiguration)', async () => {
|
||||
vi.mocked(authApi.login).mockRejectedValue(new Error('Network Error'))
|
||||
render(<LoginPage />)
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'admin' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')!)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not reach the server/)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('clears previous error before each new attempt', async () => {
|
||||
vi.mocked(authApi.login)
|
||||
.mockRejectedValueOnce(new Error('401'))
|
||||
.mockRejectedValueOnce(new Error('401'))
|
||||
.mockRejectedValueOnce({ response: { status: 401 } })
|
||||
.mockRejectedValueOnce({ response: { status: 401 } })
|
||||
render(<LoginPage />)
|
||||
const form = screen.getByRole('button', { name: /sign in/i }).closest('form')!
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } })
|
||||
|
||||
@@ -50,42 +50,48 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||
}
|
||||
|
||||
// Animated dot: slightly brighter + thicker than the base edge, travels source→target
|
||||
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
|
||||
// Normalize animated value — supports legacy boolean (true → 'snake')
|
||||
const animMode: 'none' | 'snake' | 'flow' =
|
||||
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
||||
data?.animated === 'flow' ? 'flow' : 'none'
|
||||
|
||||
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
{data?.animated && (
|
||||
{animMode === 'snake' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={dotColor}
|
||||
strokeWidth={dotWidth}
|
||||
stroke={animColor}
|
||||
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
||||
strokeDasharray="20 10000"
|
||||
strokeLinecap="round"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{isBidirectional ? (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="-10000;0;-10000"
|
||||
keyTimes="0;0.5;1"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
||||
) : (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="-10000"
|
||||
to="0"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
||||
)}
|
||||
</path>
|
||||
)}
|
||||
{animMode === 'flow' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={animColor}
|
||||
strokeWidth={Math.max(3, (style.strokeWidth as number ?? 2) * 1.8)}
|
||||
strokeDasharray="6 12"
|
||||
strokeLinecap="round"
|
||||
strokeOpacity={0.85}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
|
||||
</path>
|
||||
)}
|
||||
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
|
||||
@@ -32,12 +32,34 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
const borderStyle = rc.border_style ?? 'solid'
|
||||
const borderWidth = rc.border_width ?? 2
|
||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||
const textColor = rc.text_color ?? '#e6edf3'
|
||||
const textSize: number = rc.text_size ?? 12
|
||||
const labelPosition: string = rc.label_position ?? 'inside'
|
||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||
const posStyle = POSITION_STYLES[textPos]
|
||||
|
||||
const outsideJustify = textPos.includes('right') ? 'flex-end'
|
||||
: (textPos.includes('center') || textPos === 'center') ? 'center'
|
||||
: 'flex-start'
|
||||
|
||||
const isOutsideBottom = textPos.startsWith('bottom')
|
||||
const outsideOffset = textSize + 16
|
||||
const outsideVertical: React.CSSProperties = isOutsideBottom
|
||||
? { bottom: -outsideOffset }
|
||||
: { top: -outsideOffset }
|
||||
|
||||
const sharedTextStyle: React.CSSProperties = {
|
||||
color: textColor,
|
||||
fontFamily,
|
||||
fontSize: textSize,
|
||||
fontWeight: 500,
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeResizer
|
||||
@@ -55,6 +77,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'visible',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
@@ -62,12 +86,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
justifyContent: posStyle.justifyContent,
|
||||
padding: 12,
|
||||
background: backgroundColor,
|
||||
border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||
borderRadius: 10,
|
||||
fontFamily,
|
||||
color: textColor,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
}}
|
||||
@@ -76,8 +96,24 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
setEditingGroupRectId(id)
|
||||
}}
|
||||
>
|
||||
{data.label && (
|
||||
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
|
||||
{labelPosition === 'outside' && data.label && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...outsideVertical,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
justifyContent: outsideJustify,
|
||||
pointerEvents: 'none',
|
||||
...sharedTextStyle,
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
)}
|
||||
{labelPosition === 'inside' && data.label && (
|
||||
<span style={{ textAlign: posStyle.textAlign, ...sharedTextStyle }}>
|
||||
{data.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,14 @@ import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
||||
|
||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
||||
|
||||
type AnimMode = 'none' | 'snake' | 'flow'
|
||||
|
||||
function toAnimMode(v: EdgeData['animated']): AnimMode {
|
||||
if (v === true || v === 'snake') return 'snake'
|
||||
if (v === 'flow') return 'flow'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
interface EdgeModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -25,7 +33,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||
const [animated, setAnimated] = useState(initial?.animated ?? false)
|
||||
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
||||
|
||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||
|
||||
@@ -37,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||
custom_color: customColor,
|
||||
path_style: pathStyle,
|
||||
animated: animated || undefined,
|
||||
animated: animation !== 'none' ? animation : undefined,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
@@ -115,20 +123,25 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Flow Animation</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAnimated((a) => !a)}
|
||||
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
|
||||
style={{ background: animated ? '#00d4ff' : '#30363d' }}
|
||||
aria-pressed={animated}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
<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]">
|
||||
{(['none', '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"
|
||||
style={{
|
||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||
borderRight: i < 2 ? '1px solid #30363d' : undefined,
|
||||
}}
|
||||
>
|
||||
{mode === 'none' ? 'None' : mode === 'snake' ? 'Snake' : 'Flow'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
|
||||
@@ -8,13 +8,18 @@ import type { TextPosition } from '@/types'
|
||||
|
||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
export type LabelPosition = 'inside' | 'outside'
|
||||
|
||||
export interface GroupRectFormData {
|
||||
label: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_position: TextPosition
|
||||
text_size: number
|
||||
label_position: LabelPosition
|
||||
border_color: string
|
||||
border_style: BorderStyle
|
||||
border_width: number
|
||||
background_color: string
|
||||
z_order: number
|
||||
}
|
||||
@@ -27,13 +32,38 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] =
|
||||
{ value: 'none', label: 'None', preview: ' ' },
|
||||
]
|
||||
|
||||
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 12, label: '12' },
|
||||
{ value: 14, label: '14' },
|
||||
{ value: 16, label: '16' },
|
||||
{ value: 18, label: '18' },
|
||||
{ value: 20, label: '20' },
|
||||
]
|
||||
|
||||
const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [
|
||||
{ value: 'inside', label: 'Inside' },
|
||||
{ value: 'outside', label: 'Outside' },
|
||||
]
|
||||
|
||||
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||
{ value: 1, label: '1px' },
|
||||
{ value: 2, label: '2px' },
|
||||
{ value: 3, label: '3px' },
|
||||
{ value: 4, label: '4px' },
|
||||
{ value: 5, label: '5px' },
|
||||
]
|
||||
|
||||
const DEFAULT_FORM: GroupRectFormData = {
|
||||
label: '',
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_position: 'top-left',
|
||||
text_size: 12,
|
||||
label_position: 'inside',
|
||||
border_color: '#00d4ff',
|
||||
border_style: 'solid',
|
||||
border_width: 2,
|
||||
background_color: '#00d4ff0d',
|
||||
z_order: 1,
|
||||
}
|
||||
@@ -65,7 +95,7 @@ interface GroupRectModalProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
|
||||
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Zone' }: GroupRectModalProps) {
|
||||
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
|
||||
|
||||
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
|
||||
@@ -145,6 +175,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label position */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Label Position</Label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{LABEL_POSITIONS.map(({ value, label }) => {
|
||||
const isSelected = form.label_position === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('label_position', value)}
|
||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Colors */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||
@@ -169,6 +224,32 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text size */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Text Size</Label>
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{TEXT_SIZES.map(({ value, label }) => {
|
||||
const isSelected = form.text_size === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('text_size', value)}
|
||||
className="flex items-center justify-center h-8 rounded transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
fontSize: value,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border style */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||
@@ -196,6 +277,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border width */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Width</Label>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{BORDER_WIDTHS.map(({ value, label }) => {
|
||||
const isSelected = form.border_width === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => set('border_width', value)}
|
||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Z-order */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||
@@ -230,7 +336,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
||||
{title === 'Add Rectangle' ? 'Add' : 'Save'}
|
||||
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,26 +97,52 @@ describe('EdgeModal', () => {
|
||||
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
|
||||
})
|
||||
|
||||
// ── Animated toggle ───────────────────────────────────────────────────────
|
||||
// ── Animation select ──────────────────────────────────────────────────────
|
||||
|
||||
it('flow animation defaults to off', () => {
|
||||
it('animation defaults to None — animated omitted from payload', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
// animated: false → omitted (falsy || undefined)
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBeFalsy()
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
|
||||
})
|
||||
|
||||
it('toggling animation sends animated: true', () => {
|
||||
it('selecting Snake sends animated: "snake"', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
// Find the toggle: it's the only button with aria-pressed attribute
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const toggle = allButtons.find((b) => b.hasAttribute('aria-pressed'))!
|
||||
expect(toggle).toBeDefined()
|
||||
fireEvent.click(toggle)
|
||||
fireEvent.click(screen.getByText('Snake'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
|
||||
})
|
||||
|
||||
it('selecting Flow sends animated: "flow"', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Flow'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('flow')
|
||||
})
|
||||
|
||||
it('selecting None after Snake omits animated from payload', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Snake'))
|
||||
fireEvent.click(screen.getByText('None'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pre-fills animation from initial "snake" string', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: 'snake' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
|
||||
})
|
||||
|
||||
it('pre-fills animation from legacy initial true (backward compat)', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ animated: true }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('snake')
|
||||
})
|
||||
|
||||
// ── Pre-fill ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,14 +13,15 @@ describe('GroupRectModal', () => {
|
||||
it('renders form fields when open', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
|
||||
expect(screen.getByText('Add Rectangle')).toBeDefined()
|
||||
expect(screen.getByText('Add Zone')).toBeDefined()
|
||||
expect(screen.getByText('Text Position')).toBeDefined()
|
||||
expect(screen.getByText('Border Width')).toBeDefined()
|
||||
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Edit Rectangle title when provided', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
|
||||
expect(screen.getByText('Edit Rectangle')).toBeDefined()
|
||||
it('renders Edit Zone title when provided', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
|
||||
expect(screen.getByText('Edit Zone')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data on submit', () => {
|
||||
@@ -123,6 +124,124 @@ describe('GroupRectModal', () => {
|
||||
expect(submitted.border_style).toBe('dotted')
|
||||
})
|
||||
|
||||
it('renders Label Position section with inside/outside options', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Label Position')).toBeDefined()
|
||||
expect(screen.getByText('Inside')).toBeDefined()
|
||||
expect(screen.getByText('Outside')).toBeDefined()
|
||||
})
|
||||
|
||||
it('defaults label_position to inside', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label_position).toBe('inside')
|
||||
})
|
||||
|
||||
it('selects outside label position on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Outside'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label_position).toBe('outside')
|
||||
})
|
||||
|
||||
it('pre-fills label_position from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ label_position: 'outside' }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.label_position).toBe('outside')
|
||||
})
|
||||
|
||||
it('renders Text Size section with 6 options', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Text Size')).toBeDefined()
|
||||
expect(screen.getByText('10')).toBeDefined()
|
||||
expect(screen.getByText('20')).toBeDefined()
|
||||
})
|
||||
|
||||
it('defaults text_size to 12', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_size).toBe(12)
|
||||
})
|
||||
|
||||
it('selects text size on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('18'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_size).toBe(18)
|
||||
})
|
||||
|
||||
it('pre-fills text_size from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ text_size: 16 }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_size).toBe(16)
|
||||
})
|
||||
|
||||
it('renders Border Width section with 5 options', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Border Width')).toBeDefined()
|
||||
expect(screen.getByText('1px')).toBeDefined()
|
||||
expect(screen.getByText('3px')).toBeDefined()
|
||||
expect(screen.getByText('5px')).toBeDefined()
|
||||
})
|
||||
|
||||
it('defaults border_width to 2', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_width).toBe(2)
|
||||
})
|
||||
|
||||
it('selects border width on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('4px'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_width).toBe(4)
|
||||
})
|
||||
|
||||
it('pre-fills border_width from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ border_width: 5 }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_width).toBe(5)
|
||||
})
|
||||
|
||||
it('toggles border style — clicking selected style deselects back to solid', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus } from 'lucide-react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -10,20 +10,26 @@ interface DetailPanelProps {
|
||||
onEdit: (id: string) => void
|
||||
}
|
||||
|
||||
type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
|
||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
|
||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||
|
||||
const [addingService, setAddingService] = useState(false)
|
||||
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
|
||||
port: '',
|
||||
protocol: 'tcp',
|
||||
service_name: '',
|
||||
})
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
const [editingFor, setEditingFor] = useState<{ nodeId: string; index: number } | null>(null)
|
||||
const [editSvc, setEditSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
|
||||
if (!node || node.data.type === 'groupRect') return null
|
||||
|
||||
const addingService = addingForNode === node.id
|
||||
const editingIndex = editingFor?.nodeId === node.id ? editingFor.index : null
|
||||
|
||||
const { data } = node
|
||||
const services = data.services ?? []
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
const host = data.ip ?? data.hostname
|
||||
|
||||
@@ -41,14 +47,36 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
protocol: newSvc.protocol,
|
||||
service_name: newSvc.service_name.trim(),
|
||||
}
|
||||
updateNode(node.id, { services: [...(data.services ?? []), svc] })
|
||||
setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
|
||||
setAddingService(false)
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
setAddingForNode(null)
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
const updated = data.services.filter((_, i) => i !== index)
|
||||
const updated = services.filter((_, i) => i !== index)
|
||||
updateNode(node.id, { services: updated })
|
||||
if (editingIndex === index) setEditingFor(null)
|
||||
}
|
||||
|
||||
const handleStartEdit = (index: number) => {
|
||||
const svc = services[index]
|
||||
if (!svc) return
|
||||
setEditSvc({ port: String(svc.port), protocol: svc.protocol, service_name: svc.service_name })
|
||||
setEditingFor({ nodeId: node.id, index })
|
||||
setAddingForNode(null)
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editingIndex === null) return
|
||||
const port = parseInt(editSvc.port, 10)
|
||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex
|
||||
? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() }
|
||||
: svc
|
||||
)
|
||||
updateNode(node.id, { services: updated })
|
||||
setEditingFor(null)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -57,6 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<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"
|
||||
>
|
||||
@@ -115,10 +144,10 @@ 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{data.services.length > 0 ? ` (${data.services.length})` : ''}
|
||||
Services{services.length > 0 ? ` (${services.length})` : ''}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setAddingService((v) => !v)}
|
||||
onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }}
|
||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||
>
|
||||
<Plus size={10} /> Add
|
||||
@@ -127,67 +156,43 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
|
||||
{/* Add service form */}
|
||||
{addingService && (
|
||||
<div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={newSvc.service_name}
|
||||
onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
|
||||
placeholder="Service name"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
value={newSvc.port}
|
||||
onChange={(e) => setNewSvc((s) => ({ ...s, port: e.target.value }))}
|
||||
placeholder="Port"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
|
||||
/>
|
||||
<select
|
||||
value={newSvc.protocol}
|
||||
onChange={(e) => setNewSvc((s) => ({ ...s, protocol: e.target.value as 'tcp' | 'udp' }))}
|
||||
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={handleAddService}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setAddingService(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ServiceForm
|
||||
form={newSvc}
|
||||
onChange={setNewSvc}
|
||||
onConfirm={handleAddService}
|
||||
onCancel={() => setAddingForNode(null)}
|
||||
confirmLabel="Add"
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
|
||||
{data.services.length > 0 && (
|
||||
{services.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{data.services.map((svc, i) => (
|
||||
<ServiceBadge
|
||||
key={`${svc.port}-${svc.protocol}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
))}
|
||||
{services.map((svc, i) =>
|
||||
editingIndex === i ? (
|
||||
<ServiceForm
|
||||
key={`edit-${i}`}
|
||||
form={editSvc}
|
||||
onChange={setEditSvc}
|
||||
onConfirm={handleSaveEdit}
|
||||
onCancel={() => setEditingFor(null)}
|
||||
confirmLabel="Save"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<ServiceBadge
|
||||
key={`${svc.port}-${svc.protocol}-${i}`}
|
||||
svc={svc}
|
||||
host={host}
|
||||
onEdit={() => handleStartEdit(i)}
|
||||
onRemove={() => handleRemoveService(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.services.length === 0 && !addingService && (
|
||||
{services.length === 0 && !addingService && (
|
||||
<p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -205,7 +210,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
||||
<Edit size={14} /> Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDelete}>
|
||||
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -232,6 +237,67 @@ function DetailRow({ label, value, mono }: { label: string; value: string; mono?
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceForm({
|
||||
form,
|
||||
onChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel,
|
||||
autoFocus,
|
||||
}: {
|
||||
form: { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
||||
onChange: (f: { port: string; protocol: 'tcp' | 'udp'; service_name: string }) => void
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
confirmLabel: string
|
||||
autoFocus?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 mb-1 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||
<Input
|
||||
value={form.service_name}
|
||||
onChange={(e) => onChange({ ...form, service_name: e.target.value })}
|
||||
placeholder="Service name"
|
||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||
autoFocus={autoFocus}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={(e) => onChange({ ...form, port: e.target.value })}
|
||||
placeholder="Port"
|
||||
min={1}
|
||||
max={65535}
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
|
||||
onKeyDown={(e) => e.key === 'Enter' && onConfirm()}
|
||||
/>
|
||||
<select
|
||||
value={form.protocol}
|
||||
onChange={(e) => onChange({ ...form, protocol: e.target.value as 'tcp' | 'udp' })}
|
||||
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
|
||||
>
|
||||
<option value="tcp">tcp</option>
|
||||
<option value="udp">udp</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff',
|
||||
database: '#a855f7',
|
||||
@@ -241,7 +307,17 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
remote: '#8b949e',
|
||||
}
|
||||
|
||||
function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
|
||||
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'
|
||||
|
||||
@@ -261,6 +337,13 @@ function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-mono text-[#8b949e]">{svc.port}/{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"
|
||||
|
||||
@@ -124,7 +124,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||
<SidebarItem icon={Square} label="Add Rectangle" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
<SidebarItem
|
||||
icon={hideIp ? EyeOff : Eye}
|
||||
@@ -303,7 +303,7 @@ function HiddenDevicesPanel() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useState(() => { load() })
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleIgnore = async (id: string) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DetailPanel } from '../DetailPanel'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -115,4 +115,170 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText('4 TB')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Panel actions', () => {
|
||||
it('calls setSelectedNode(null) when close button is clicked', () => {
|
||||
const setSelectedNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode,
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByLabelText('Close panel'))
|
||||
expect(setSelectedNode).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('calls onEdit with node id when Edit button is clicked', () => {
|
||||
setupStore({})
|
||||
const onEdit = vi.fn()
|
||||
render(<DetailPanel onEdit={onEdit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }))
|
||||
expect(onEdit).toHaveBeenCalledWith('n1')
|
||||
})
|
||||
|
||||
it('calls deleteNode when delete confirmed', () => {
|
||||
const deleteNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ label: 'My Server' })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode,
|
||||
updateNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByLabelText('Delete node'))
|
||||
expect(deleteNode).toHaveBeenCalledWith('n1')
|
||||
})
|
||||
|
||||
it('does not call deleteNode when delete is cancelled', () => {
|
||||
const deleteNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode,
|
||||
updateNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByLabelText('Delete node'))
|
||||
expect(deleteNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Services — add/remove', () => {
|
||||
it('shows add form when Add is clicked', () => {
|
||||
setupStore({})
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(screen.getByPlaceholderText('Service name')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls updateNode with new service on Add confirm', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({})],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } })
|
||||
// Two "Add" buttons exist: the header toggle and the form confirm — pick the form's
|
||||
const addButtons = screen.getAllByRole('button', { name: 'Add' })
|
||||
fireEvent.click(addButtons[addButtons.length - 1])
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' })
|
||||
})
|
||||
|
||||
it('calls updateNode without the removed service when X is clicked', () => {
|
||||
const updateNode = vi.fn()
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [svc] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Remove service'))
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not crash when data.services is undefined', () => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: undefined as unknown as [] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Services — edit', () => {
|
||||
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
|
||||
|
||||
it('shows edit form pre-filled when pencil is clicked', () => {
|
||||
setupStore({ services: [svc] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
// Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only)
|
||||
const editBtn = screen.getByTitle('Edit service')
|
||||
fireEvent.click(editBtn)
|
||||
const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement
|
||||
expect(nameInput.value).toBe('nginx')
|
||||
const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement
|
||||
expect(portInput.value).toBe('80')
|
||||
})
|
||||
|
||||
it('calls updateNode with updated values on Save', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [svc] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Edit service'))
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Service name')
|
||||
fireEvent.change(nameInput, { target: { value: 'apache' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(updateNode).toHaveBeenCalledOnce()
|
||||
expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache')
|
||||
expect(updateNode.mock.calls[0][1].services[0].port).toBe(80)
|
||||
})
|
||||
|
||||
it('cancels edit without updating', () => {
|
||||
const updateNode = vi.fn()
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode({ services: [svc] })],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Edit service'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(updateNode).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('nginx')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -98,18 +98,32 @@ describe('canvasStore', () => {
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('onNodesChange marks unsaved', () => {
|
||||
it('onNodesChange marks unsaved for position changes', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }])
|
||||
useCanvasStore.getState().onNodesChange([{ type: 'position', id: 'n1', dragging: false }])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('onEdgesChange marks unsaved', () => {
|
||||
it('onNodesChange does not mark unsaved for select-only changes', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().onNodesChange([{ type: 'select', id: 'n1', selected: true }])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('onEdgesChange marks unsaved for remove changes', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().onEdgesChange([{ type: 'remove', id: 'e1' }])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('onEdgesChange does not mark unsaved for select-only changes', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().onEdgesChange([{ type: 'select', id: 'e1', selected: true }])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('onConnect adds an edge between two nodes', () => {
|
||||
@@ -130,6 +144,13 @@ describe('canvasStore', () => {
|
||||
expect(edges[0].data?.label).toBe('uplink')
|
||||
})
|
||||
|
||||
it('onConnect preserves animated from edge data', () => {
|
||||
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: null, targetHandle: null }, { type: 'ethernet', animated: 'snake' })
|
||||
useCanvasStore.getState().onConnect(conn)
|
||||
const { edges } = useCanvasStore.getState()
|
||||
expect(edges[0].data?.animated).toBe('snake')
|
||||
})
|
||||
|
||||
it('onConnect preserves sourceHandle and targetHandle for cluster edges', () => {
|
||||
const conn = Object.assign({ source: 'n1', target: 'n2', sourceHandle: 'cluster-right', targetHandle: 'cluster-left' }, { type: 'cluster' })
|
||||
useCanvasStore.getState().onConnect(conn)
|
||||
@@ -140,6 +161,15 @@ describe('canvasStore', () => {
|
||||
expect(edges[0].type).toBe('cluster')
|
||||
})
|
||||
|
||||
it('deleteNode also removes children with matching parentId', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('parent'))
|
||||
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
||||
useCanvasStore.getState().deleteNode('parent')
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes.find((n) => n.id === 'parent')).toBeUndefined()
|
||||
expect(nodes.find((n) => n.id === 'child')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('addNode with parent_id sets parentId and extent', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('parent'))
|
||||
useCanvasStore.getState().addNode(makeNode('child', { parent_id: 'parent' }))
|
||||
|
||||
@@ -127,13 +127,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
onNodesChange: (changes) =>
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes),
|
||||
hasUnsavedChanges: true,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||
})),
|
||||
|
||||
onEdgesChange: (changes) =>
|
||||
set((state) => ({
|
||||
edges: applyEdgeChanges(changes, state.edges),
|
||||
hasUnsavedChanges: true,
|
||||
hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'),
|
||||
})),
|
||||
|
||||
onConnect: (connection) =>
|
||||
@@ -150,7 +150,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
sourceHandle: normalizeHandle(extra.sourceHandle),
|
||||
targetHandle: normalizeHandle(extra.targetHandle),
|
||||
type: edgeType,
|
||||
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style },
|
||||
data: { type: edgeType, label: extra.label, vlan_id: extra.vlan_id, custom_color: extra.custom_color, path_style: extra.path_style, animated: extra.animated },
|
||||
}, state.edges),
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
@@ -163,10 +163,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
const enriched = node.data.parent_id
|
||||
? { ...node, parentId: node.data.parent_id, extent: 'parent' as const }
|
||||
: node
|
||||
// Parents must come before children in the array
|
||||
// Parents must come before children in the array (React Flow requirement)
|
||||
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||
if (enriched.parentId) {
|
||||
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
|
||||
const parentIdx = withoutNew.findIndex((n) => n.id === enriched.parentId)
|
||||
const insertAt = parentIdx >= 0 ? parentIdx + 1 : withoutNew.length
|
||||
const nodes = [...withoutNew.slice(0, insertAt), enriched, ...withoutNew.slice(insertAt)]
|
||||
return { nodes, hasUnsavedChanges: true }
|
||||
}
|
||||
return { nodes: [...withoutNew, enriched], hasUnsavedChanges: true }
|
||||
}),
|
||||
@@ -180,12 +183,20 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
})),
|
||||
|
||||
deleteNode: (id) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.filter((n) => n.id !== id),
|
||||
edges: state.edges.filter((e) => e.source !== id && e.target !== id),
|
||||
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
set((state) => {
|
||||
const idsToRemove = new Set<string>()
|
||||
const collect = (nodeId: string) => {
|
||||
idsToRemove.add(nodeId)
|
||||
state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
|
||||
}
|
||||
collect(id)
|
||||
return {
|
||||
nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
|
||||
edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
|
||||
selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId,
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
}),
|
||||
|
||||
updateEdge: (id, data) =>
|
||||
set((state) => ({
|
||||
@@ -245,6 +256,6 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
// React Flow requires parents before children in the array
|
||||
const parents = nodes.filter((n) => !n.parentId)
|
||||
const children = nodes.filter((n) => !!n.parentId)
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null })
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [] })
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -72,6 +72,9 @@ export interface NodeData extends Record<string, unknown> {
|
||||
text_position?: TextPosition
|
||||
font?: string
|
||||
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
border_width?: number
|
||||
label_position?: 'inside' | 'outside'
|
||||
text_size?: number
|
||||
z_order?: number
|
||||
width?: number
|
||||
height?: number
|
||||
@@ -88,7 +91,7 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
speed?: string
|
||||
custom_color?: string
|
||||
path_style?: EdgePathStyle
|
||||
animated?: boolean
|
||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||
}
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ApiEdge {
|
||||
speed?: string | null
|
||||
custom_color?: string | null
|
||||
path_style?: string | null
|
||||
animated?: boolean
|
||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||
source_handle?: string | null
|
||||
target_handle?: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user