Compare commits

..

24 Commits

Author SHA1 Message Date
Pouzor df3b7a8cb0 fix: show validation error when label is empty in NodeModal
Silent return when label was empty made the Add button appear broken.
Native browser required tooltip doesn't render in Radix Dialog portals.
Now shows inline red error message and highlights the field.
2026-03-16 01:01:47 +01:00
Remy 426af29180 Merge pull request #2 from Pouzor/feat/mcp-server
feat: MCP server for AI integration
2026-03-16 00:53:56 +01:00
Pouzor f36bdfe878 update package 2026-03-16 00:51:12 +01:00
Pouzor 300567c88d feat(mcp): expose parent_id in update_node tool
- Add parent_id to NodeUpdate schema in backend so PATCH /nodes/{id}
  accepts it (was silently ignored before)
- Expose parent_id in update_node MCP tool schema so the MCP SDK
  forwards it to the backend instead of stripping it
- Add regression tests in both backend and MCP layers
2026-03-13 21:29:27 +01:00
Pouzor e41dbe579c fix(mcp): fix SSE streaming crash and reduce get_canvas token usage
- Replace BaseHTTPMiddleware with pure ASGI middleware in auth.py to fix
  the "Unexpected message: http.response.start" crash on SSE streams
- Migrate from SseServerTransport to StreamableHTTPSessionManager in main.py
- Add _slim_canvas() in tools.py to strip React Flow layout fields from
  get_canvas responses (60-80% payload reduction)
- Update tests: assert canvas slimming, mock session_manager.handle_request
  in auth tests to avoid uninitialized task group errors
2026-03-13 17:28:38 +01:00
Pouzor e1d16b86e3 docs: fix Claude Code MCP setup instructions (use claude mcp add CLI command) 2026-03-13 16:52:36 +01:00
Pouzor 593335648f chore: add mcp/.env.example 2026-03-13 16:45:51 +01:00
Pouzor e3f8c27a04 test: add backend tests for MCP service key auth in deps.py 2026-03-13 16:42:48 +01:00
Pouzor ff69856d31 chore: ignore Ideas.md 2026-03-13 16:42:13 +01:00
Pouzor 7935b671d3 feat: add MCP server with HTTP/SSE transport for AI integration
Exposes homelab topology to MCP-compatible AI clients (Claude Code, etc.)
over LAN via HTTP/SSE on port 8001.

- New mcp/ service: FastAPI + mcp SDK, SSE transport
- Auth: X-API-Key for AI clients, X-MCP-Service-Key for backend (Docker-internal)
- Resources: canvas, nodes, edges, scan/pending, scan/runs
- Tools: create/update/delete nodes+edges, trigger scan, approve/hide devices
- Backend deps.py: accepts JWT or MCP service key (no plain-text password)
- 40 tests (auth, resources, tools) across asyncio + trio
- docker-compose.yml: mcp service on port 8001
- README: MCP setup section with Claude Code/Desktop config examples
2026-03-13 16:41:34 +01:00
Pouzor f8cadba17b feat: export node inventory as Markdown table (copy to clipboard) 2026-03-13 12:36:31 +01:00
Pouzor 5f7cb1bf11 feat: make hostname clickable in detail panel (opens in new tab) 2026-03-13 12:31:07 +01:00
Pouzor 3fb3bf016b fix: snapshot history on drag start so undo restores pre-move position 2026-03-12 12:06:04 +01:00
Pouzor ba032a45af feat: canvas history (undo/redo), copy/paste nodes, node search, shortcuts modal
- Undo/Redo (Ctrl+Z / Ctrl+Y): 50-entry snapshot stack in canvasStore; snapshot before all mutations and on node drag stop
- Copy/Paste (Ctrl+C / Ctrl+V): copy selected nodes to clipboard, paste with +50px offset and new IDs
- Node search (Ctrl+K): spotlight overlay — fuzzy search by label/IP/hostname, jumps + focuses matched node
- Shortcuts modal (?): lists all keyboard shortcuts, accessible via ? key or toolbar ? button
- Toolbar: undo/redo buttons (disabled when stack empty), ? help button
2026-03-12 11:56:38 +01:00
Pouzor 68b35a0c30 feat: improve edge flow animation speed, size, and direction
- Slow down animation (6s → 10s, proxmox cluster 20s)
- Larger dot: length 20, width 2× edge stroke
- Reverse direction to travel parent→child
- Proxmox-to-proxmox edges ping-pong (bidirectional cluster animation)
2026-03-12 10:47:12 +01:00
Pouzor 41cfccbd37 feat: add edge flow animation (dot traveling source→target)
- Add animated toggle per edge in EdgeModal (cyan switch, "Flow Animation")
- SVG-native <animate> element for reliable cross-browser dot animation
- Persist animated field: backend model, schemas (EdgeBase/EdgeUpdate/EdgeSave), DB migration
- Include animated in App.tsx edgesToSave serialization so it survives save/reload
- Add animated: bool to EdgeData TypeScript type
2026-03-12 10:23:18 +01:00
Pouzor 55a842cdad Update Readme 2026-03-11 16:41:40 +01:00
Pouzor 7074c5387b docs: add screenshots to README 2026-03-11 16:38:51 +01:00
Pouzor 15f210470a fix: remove duplicate hideIp property in canvasStore 2026-03-11 16:21:15 +01:00
Pouzor 271bbf2d01 feat: add hide IP toggle in sidebar
- Add hideIp / toggleHideIp to canvasStore (UI-only, not persisted)
- Add maskIp util: 192.168.1.115 → 192.168.XX.XX (non-IPv4 passthrough)
- BaseNode reads hideIp from store and masks last two octets when active
- Sidebar shows Eye/EyeOff toggle below Scan Network, highlights when active
2026-03-11 16:15:22 +01:00
Pouzor 8affcda09d fix: improve auto-layout peer alignment and edge direction
- Exclude peer-to-peer edges from Dagre to keep same-type nodes at same rank
- Snap peer group Y to average, re-space X preserving user's original order
- Reverse upward edges (sourceHandle=top) in Dagre so child nodes always render below parent
- Add layout.test.ts covering peer alignment, X ordering, and upward edge cases
2026-03-11 14:29:29 +01:00
Pouzor 92d505f78c feat: add logo, favicon, and theme system
- Add custom SVG favicon and Logo component (house + network nodes motif)
- Update page title to Homelable with meta description
- Show Logo in sidebar header and toolbar
- Add theme store and ThemeModal for canvas style switching
- Refactor node colors and edge styles for theme support
2026-03-11 14:29:15 +01:00
Pouzor 16de7cd390 fix: accept string | null in Select onValueChange (Base UI type) 2026-03-10 17:40:21 +01:00
Pouzor 0fb091b12c fix: resolve TS build errors for CI
- Add groupRect entry to NODE_DEFAULT_COLORS (required by Record<NodeType, NodeColors>)
- Explicitly type Select onValueChange callbacks as (v: string) to satisfy
  stricter type inference in tsc -b build mode
2026-03-10 17:37:00 +01:00
68 changed files with 2745 additions and 147 deletions
+7
View File
@@ -15,3 +15,10 @@ SCANNER_RANGES=["192.168.1.0/24"]
# Status checker interval in seconds
STATUS_CHECKER_INTERVAL=60
# MCP server — used by the mcp service (port 8001)
# MCP_API_KEY: authenticates AI clients (Claude Code, etc.) → MCP server
# MCP_SERVICE_KEY: authenticates MCP server → backend (never exposed externally)
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
MCP_API_KEY=mcp_sk_changeme
MCP_SERVICE_KEY=svc_changeme
+1
View File
@@ -48,3 +48,4 @@ htmlcov/
# Docker
.docker/
Ideas.md
+100
View File
@@ -4,9 +4,21 @@ Homelable is a self-hosted infrastructure visualization solution. It provides a
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
You can also select some pre-built design styles, or personalize each device in your diagram.
If you just like the design, you can only run the frontend and export your design as PNG.
---
## Screenshots
<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="100%" />
</p>
---
## Quick Start — Docker
@@ -150,6 +162,94 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
---
## MCP Server (AI Integration)
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
### What the AI can do
| | Action |
|---|---|
| **Read** | List all nodes, edges, full canvas, pending devices, scan history |
| **Write** | Add / update / delete nodes and edges, trigger a network scan, approve or hide discovered devices |
### Setup
**1. Add the keys to your `.env`:**
```env
# Authenticates AI clients (Claude Code, etc.) → MCP server
MCP_API_KEY=mcp_sk_changeme
# Authenticates MCP server → backend (internal Docker network only, never exposed)
MCP_SERVICE_KEY=svc_changeme
# Generate both with:
# python3 -c "import secrets; print(secrets.token_hex(32))"
```
No plain-text passwords involved — `AUTH_PASSWORD_HASH` is only used for the web UI login.
**2. Start the MCP service:**
```bash
docker compose up -d mcp
# MCP server is now listening on http://<your-homelab-ip>:8001
```
**3. Configure your AI client:**
**Claude Code** — run this command in your terminal:
```bash
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
--header "X-API-Key: mcp_sk_yourkey"
```
Or add it manually to `~/.claude.json`:
```json
{
"mcpServers": {
"homelable": {
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
}
}
}
```
**Claude Desktop** — edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"homelable": {
"type": "sse",
"url": "http://<your-homelab-ip>:8001/mcp",
"headers": {
"X-API-Key": "mcp_sk_yourkey"
}
}
}
}
```
### Example prompts
- *"What nodes are currently offline?"*
- *"Add a new LXC container named `pihole` at 192.168.1.5, connected to my switch."*
- *"Trigger a network scan on 192.168.1.0/24 and show me the pending devices."*
- *"Show me the full canvas topology."*
### Security
- The MCP server is **not** intended to be exposed to the internet — keep port 8001 firewalled to your LAN.
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`.
- The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
---
## Development Mode
**Backend (Python 3.13):**
+21 -3
View File
@@ -1,12 +1,30 @@
from fastapi import Depends, HTTPException, status
import hmac
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from app.core.security import decode_token
bearer = HTTPBearer()
bearer = HTTPBearer(auto_error=False)
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
x_mcp_service_key: str | None = Header(default=None),
) -> str:
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
if x_mcp_service_key is not None:
if not settings.mcp_service_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
return "__mcp_service__"
# 2. Standard JWT bearer token
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
+5
View File
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
# Status checker
status_checker_interval: int = 60
# MCP service key — set MCP_SERVICE_KEY in .env
# Used by the MCP server to authenticate against the backend without a user password.
# Leave empty to disable MCP service key auth.
mcp_service_key: str = ""
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
+2
View File
@@ -40,6 +40,8 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(Exception):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+1
View File
@@ -58,6 +58,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)
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
View File
@@ -37,6 +37,7 @@ class EdgeSave(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
animated: bool = False
source_handle: str | None = None
target_handle: str | None = None
+2
View File
@@ -12,6 +12,7 @@ class EdgeBase(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
animated: bool = False
source_handle: str | None = None
target_handle: str | None = None
@@ -27,6 +28,7 @@ class EdgeUpdate(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
animated: bool | None = None
source_handle: str | None = None
target_handle: str | None = None
+1
View File
@@ -42,6 +42,7 @@ class NodeUpdate(BaseModel):
notes: str | None = None
pos_x: float | None = None
pos_y: float | None = None
parent_id: str | None = None
container_mode: bool | None = None
custom_colors: dict[str, Any] | None = None
custom_icon: str | None = None
+28
View File
@@ -1,3 +1,4 @@
import pytest
from httpx import AsyncClient
@@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient):
res = await client.get("/api/v1/health")
assert res.status_code == 200
assert res.json() == {"status": "ok"}
# --- MCP service key auth ---
@pytest.fixture
def with_service_key():
from app.core.config import settings
settings.mcp_service_key = "test-service-key"
yield "test-service-key"
settings.mcp_service_key = ""
async def test_service_key_grants_access(client: AsyncClient, with_service_key):
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": with_service_key})
assert res.status_code == 200
async def test_service_key_wrong_value(client: AsyncClient, with_service_key):
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "wrong-key"})
assert res.status_code == 401
async def test_service_key_disabled_when_not_configured(client: AsyncClient):
from app.core.config import settings
settings.mcp_service_key = ""
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
assert res.status_code == 401
+10
View File
@@ -102,6 +102,16 @@ async def test_update_node_container_mode(client: AsyncClient, headers: dict):
assert res.json()["container_mode"] is True
async def test_update_node_parent_id(client: AsyncClient, headers: dict):
parent = await client.post("/api/v1/nodes", json={"type": "proxmox", "label": "PVE", "status": "unknown"}, headers=headers)
parent_id = parent.json()["id"]
child = await client.post("/api/v1/nodes", json={"type": "lxc", "label": "Child", "status": "unknown"}, headers=headers)
child_id = child.json()["id"]
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": parent_id}, headers=headers)
assert res.status_code == 200
assert res.json()["parent_id"] == parent_id
async def test_create_node_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
assert res.status_code == 401
+16
View File
@@ -18,6 +18,22 @@ services:
cap_add:
- NET_RAW
mcp:
build:
context: ./mcp
dockerfile: Dockerfile.mcp
restart: unless-stopped
ports:
- "8001:8001"
env_file:
- .env
environment:
BACKEND_URL: "http://backend:8000"
depends_on:
- backend
networks:
- homelable
frontend:
build:
context: .
Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

+3 -2
View File
@@ -2,9 +2,10 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<meta name="description" content="Homelable — Visual homelab infrastructure map with live monitoring" />
<title>Homelable</title>
</head>
<body>
<div id="root"></div>
+11 -11
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "0.0.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "0.0.0",
"version": "1.0.0",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -5474,9 +5474,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},
@@ -5826,9 +5826,9 @@
}
},
"node_modules/hono": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
"version": "4.12.8",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -8770,9 +8770,9 @@
}
},
"node_modules/undici": {
"version": "7.22.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
"version": "7.24.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
"dev": true,
"license": "MIT",
"engines": {
+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Background circle -->
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<!-- House silhouette -->
<path d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<!-- Door -->
<rect x="27" y="40" width="10" height="12" rx="1"
fill="#0d1117" stroke="#00d4ff" stroke-width="1"/>
<!-- Network nodes -->
<!-- Center node (hub) -->
<circle cx="32" cy="33" r="3" fill="#00d4ff"/>
<!-- Left node -->
<circle cx="22" cy="38" r="2" fill="#39d353"/>
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
<!-- Right node -->
<circle cx="42" cy="38" r="2" fill="#39d353"/>
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
<!-- Top node (inside roof area) -->
<circle cx="32" cy="24" r="2" fill="#a855f7"/>
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" stroke-width="1" opacity="0.7"/>
<!-- Glow effect on center node -->
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+79 -19
View File
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { exportToPng } from '@/utils/export'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Toaster } from '@/components/ui/sonner'
@@ -15,8 +16,12 @@ import { NodeModal } from '@/components/modals/NodeModal'
import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
@@ -26,12 +31,16 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore()
const { loadCanvas, markSaved, selectedNodeId, 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()
useStatusPolling()
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null)
@@ -43,7 +52,7 @@ export default function App() {
const handleSave = useCallback(async () => {
try {
if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges }))
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
markSaved()
toast.success('Canvas saved')
return
@@ -107,18 +116,19 @@ export default function App() {
speed: e.data?.speed ?? null,
custom_color: e.data?.custom_color ?? null,
path_style: e.data?.path_style ?? null,
animated: e.data?.animated ?? false,
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
// map them back to their canonical source handle ID so reload works correctly.
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null),
}))
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} })
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
markSaved()
toast.success('Canvas saved')
} catch {
toast.error('Save failed')
}
}, [nodes, edges, markSaved])
}, [nodes, edges, markSaved, activeTheme])
// Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave)
@@ -130,7 +140,8 @@ export default function App() {
try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
if (theme_id) setTheme(theme_id)
loadCanvas(savedNodes, savedEdges)
} else {
loadCanvas(demoNodes, demoEdges)
@@ -185,27 +196,48 @@ export default function App() {
targetHandle: e.target_handle ?? null,
data: e,
}))
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
loadCanvas(rfNodes, rfEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
})
.catch(() => loadCanvas(demoNodes, demoEdges))
}, [isAuthenticated, loadCanvas])
}, [isAuthenticated, loadCanvas, setTheme])
// Ctrl+S
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo)
const redoRef = useRef(redo)
const copyRef = useRef(copySelectedNodes)
const pasteRef = useRef(pasteNodes)
useEffect(() => { undoRef.current = undo }, [undo])
useEffect(() => { redoRef.current = redo }, [redo])
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
// Global keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSaveRef.current()
}
const ctrl = e.ctrlKey || e.metaKey
// Ignore shortcuts when typing in an input/textarea
const tag = (e.target as HTMLElement).tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable
if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return }
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [])
const handleAddNode = useCallback((data: Partial<NodeData>) => {
snapshotHistory()
const id = crypto.randomUUID()
const isProxmox = data.type === 'proxmox'
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
@@ -224,9 +256,10 @@ export default function App() {
}
addNode(newNode)
toast.success(`Added "${data.label}"`)
}, [addNode, nodes])
}, [addNode, nodes, snapshotHistory])
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
snapshotHistory()
const id = crypto.randomUUID()
const newNode: Node<NodeData> = {
id,
@@ -251,7 +284,7 @@ export default function App() {
zIndex: data.z_order - 10,
}
addNode(newNode)
}, [addNode])
}, [addNode, snapshotHistory])
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
if (!editingGroupRectId) return
@@ -274,9 +307,10 @@ export default function App() {
const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return
snapshotHistory()
deleteNode(editingGroupRectId)
setEditingGroupRectId(null)
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
}, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory])
const handleEditNode = useCallback((id: string) => {
setEditNodeId(id)
@@ -284,6 +318,7 @@ export default function App() {
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
if (!editNodeId) return
snapshotHistory()
const existingNode = nodes.find((n) => n.id === editNodeId)
updateNode(editNodeId, data)
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
@@ -313,7 +348,7 @@ export default function App() {
}
}
setEditNodeId(null)
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect])
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
const handleAutoLayout = useCallback(() => {
const laid = applyDagreLayout(nodes, edges)
@@ -321,6 +356,13 @@ export default function App() {
toast.success('Canvas auto-arranged')
}, [nodes, edges, loadCanvas])
const handleExportMd = useCallback(async () => {
const md = generateMarkdownTable(nodes)
if (!md) { toast.error('No nodes to export'); return }
await navigator.clipboard.writeText(md)
toast.success('Markdown table copied to clipboard')
}, [nodes])
const handleExport = useCallback(async () => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
@@ -338,6 +380,7 @@ export default function App() {
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
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
if (edgeData.type === 'virtual') {
@@ -352,7 +395,7 @@ export default function App() {
}
}
setPendingConnection(null)
}, [pendingConnection, onConnect, nodes, updateNode])
}, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory])
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
setEditEdgeId(edge.id)
@@ -360,15 +403,17 @@ export default function App() {
const handleEdgeUpdate = useCallback((data: EdgeData) => {
if (!editEdgeId) return
snapshotHistory()
updateEdge(editEdgeId, data)
setEditEdgeId(null)
}, [editEdgeId, updateEdge])
}, [editEdgeId, updateEdge, snapshotHistory])
const handleEdgeDelete = useCallback(() => {
if (!editEdgeId) return
snapshotHistory()
deleteEdge(editEdgeId)
setEditEdgeId(null)
}, [editEdgeId, deleteEdge])
}, [editEdgeId, deleteEdge, snapshotHistory])
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
@@ -391,10 +436,15 @@ export default function App() {
onSave={handleSave}
onAutoLayout={handleAutoLayout}
onExport={handleExport}
onChangeStyle={() => setThemeModalOpen(true)}
onUndo={undo}
onRedo={redo}
onShortcuts={() => setShortcutsOpen(true)}
onExportMd={handleExportMd}
/>
<div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
</div>
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
</div>
@@ -481,6 +531,16 @@ export default function App() {
title="Edit Rectangle"
/>
{/* key forces re-mount on open so useState captures current theme as original */}
<ThemeModal
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
/>
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
<Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider>
</TooltipProvider>
@@ -11,6 +11,8 @@ import {
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import type { NodeData, EdgeData } from '@/types'
@@ -18,15 +20,19 @@ import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
onConnect?: (connection: Connection) => void
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
onNodeDragStart?: () => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
const {
nodes, edges,
onNodesChange, onEdgesChange,
setSelectedNode,
} = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
setSelectedNode(node.id)
}, [setSelectedNode])
@@ -39,9 +45,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
onEdgeDoubleClick?.(edge)
}, [onEdgeDoubleClick])
return (
<div className="w-full h-full">
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -51,12 +56,13 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
onNodeClick={onNodeClick}
onPaneClick={onPaneClick}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDragStart={onNodeDragStart}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
snapToGrid
snapGrid={[16, 16]}
fitView
colorMode="dark"
colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose}
isValidConnection={(connection) => connection.source !== connection.target}
@@ -65,7 +71,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
variant={BackgroundVariant.Dots}
gap={24}
size={1}
color="#30363d"
color={theme.colors.canvasDotColor}
/>
<Controls />
</ReactFlow>
+58 -14
View File
@@ -3,10 +3,13 @@ import {
EdgeLabelRenderer,
getBezierPath,
getSmoothStepPath,
useStore,
type EdgeProps,
type Edge,
} from '@xyflow/react'
import type { EdgeData, EdgeType } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -15,42 +18,83 @@ function getVlanColor(vlanId?: number): string {
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
}
const EDGE_STYLES: Record<EdgeType, React.CSSProperties> = {
ethernet: { stroke: '#30363d', strokeWidth: 2 },
wifi: { stroke: '#00d4ff', strokeWidth: 1.5, strokeDasharray: '6 3' },
iot: { stroke: '#e3b341', strokeWidth: 1.5, strokeDasharray: '2 4' },
vlan: { strokeWidth: 2.5 },
virtual: { stroke: '#8b949e', strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: '#ff6e00', strokeWidth: 2.5, strokeDasharray: '8 3' },
}
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
: getBezierPath(pathArgs)
const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors
const BASE_STYLES: Record<EdgeType, React.CSSProperties> = {
ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 },
wifi: { stroke: edgeColors.wifi, strokeWidth: 1.5, strokeDasharray: '6 3' },
iot: { stroke: edgeColors.iot, strokeWidth: 1.5, strokeDasharray: '2 4' },
vlan: { strokeWidth: 2.5 },
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
}
const customColor = data?.custom_color as string | undefined
const style: React.CSSProperties = {
...EDGE_STYLES[edgeType],
...BASE_STYLES[edgeType],
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
...(customColor ? { stroke: customColor } : {}),
...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}),
...(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
return (
<>
<BaseEdge id={id} path={edgePath} style={style} />
{data?.animated && (
<path
d={edgePath}
fill="none"
stroke={dotColor}
strokeWidth={dotWidth}
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"
from="-10000"
to="0"
dur="10s"
repeatCount="indefinite"
/>
)}
</path>
)}
{data?.label && (
<EdgeLabelRenderer>
<div
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: '#161b22',
color: '#8b949e',
border: '1px solid #30363d',
background: theme.colors.edgeLabelBackground,
color: theme.colors.edgeLabelColor,
border: `1px solid ${theme.colors.edgeLabelBorder}`,
}}
>
{data.label as string}
@@ -1,25 +1,26 @@
import { createElement } from 'react'
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
import { type LucideIcon } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons'
const STATUS_COLORS: Record<NodeStatus, string> = {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
}
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp } from '@/utils/maskIp'
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon
}
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
const colors = resolveNodeColors(data)
const statusColor = STATUS_COLORS[data.status]
const colors = resolveNodeColors(data, activeTheme)
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
return (
@@ -38,25 +39,41 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
minWidth: 140,
}}
>
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" />
<Handle
type="source"
position={Position.Top}
id="top"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Icon */}
<div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
style={{
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
background: theme.colors.nodeIconBackground,
}}
>
{createElement(resolvedIcon, { size: 15 })}
</div>
{/* Details */}
<div className="flex flex-col min-w-0">
<div className="text-xs font-medium leading-tight truncate max-w-[110px]" title={data.label}>
<div
className="text-xs font-medium leading-tight truncate max-w-[110px]"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label}
</div>
{data.ip && (
<div className="font-mono text-[10px] text-[#8b949e] truncate" title={data.ip}>
{data.ip}
<div
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
</div>
)}
</div>
@@ -68,7 +85,12 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
title={data.status}
/>
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" />
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
</div>
)
@@ -1,34 +1,46 @@
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types'
import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { BaseNode } from './BaseNode'
const STATUS_COLORS: Record<NodeStatus, string> = {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
}
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props
const colors = resolveNodeColors(data)
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, activeTheme)
// Render as a regular node when container mode is disabled
if (data.container_mode === false) {
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return (
<>
<BaseNode {...props} icon={Layers} />
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
<Handle
type="source"
position={Position.Left}
id="cluster-left"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
<Handle
type="source"
position={Position.Right}
id="cluster-right"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
</>
)
}
const statusColor = STATUS_COLORS[data.status]
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return (
<>
@@ -37,7 +49,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
minHeight={160}
isVisible={selected}
lineStyle={{ borderColor: glow, opacity: 0.6 }}
handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }}
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
/>
{/* Group border */}
@@ -56,38 +68,78 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
{/* Header bar */}
<div
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
style={{ background: isOnline ? `${glow}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${glow}33` : '#30363d'}` }}
style={{
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
}}
>
<div
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }}
style={{
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
background: theme.colors.nodeIconBackground,
}}
>
<Layers size={12} />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? glow : '#c9d1d9' }}>
<span
className="text-[11px] font-semibold leading-tight truncate"
style={{ color: isOnline ? glow : theme.colors.nodeLabelColor }}
>
{data.label}
</span>
{data.ip && (
<span className="font-mono text-[9px] text-[#8b949e] truncate">{data.ip}</span>
<span
className="font-mono text-[9px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
>
{data.ip}
</span>
)}
</div>
{/* Status dot */}
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} title={data.status} />
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
</div>
{/* Inner area — React Flow places children here */}
<div className="flex-1 relative" />
</div>
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" />
<Handle
type="source"
position={Position.Top}
id="top"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" />
<Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Cluster handles — left/right for same-cluster links */}
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} />
{/* Cluster handles */}
<Handle
type="source"
position={Position.Left}
id="cluster-left"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
<Handle
type="source"
position={Position.Right}
id="cluster-right"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
</>
)
}
@@ -25,6 +25,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 effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
@@ -36,6 +37,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,
})
onClose()
}
@@ -113,6 +115,22 @@ 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>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Color</Label>
@@ -93,7 +93,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
{/* Font */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={form.font} onValueChange={(v) => set('font', v)}>
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
@@ -160,7 +160,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
{/* Z-order */}
<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) => set('z_order', Number(v))}>
<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">
<SelectValue />
</SelectTrigger>
+9 -4
View File
@@ -43,13 +43,18 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = useState(false)
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.label?.trim()) return
if (!form.label?.trim()) {
setLabelError(true)
return
}
setLabelError(false)
onSubmit(form)
onClose()
}
@@ -167,11 +172,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<Label className="text-xs text-muted-foreground">Label *</Label>
<Input
value={form.label ?? ''}
onChange={(e) => set('label', e.target.value)}
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
required
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
/>
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div>
{/* Hostname */}
@@ -0,0 +1,84 @@
import { useState, useCallback } from 'react'
import { useReactFlow } from '@xyflow/react'
import { Search } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
interface SearchModalProps {
open: boolean
onClose: () => void
}
export function SearchModal({ open, onClose }: SearchModalProps) {
const [query, setQuery] = useState('')
const nodes = useCanvasStore((s) => s.nodes)
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const { fitView } = useReactFlow()
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
const q = query.toLowerCase()
const results = q.length === 0 ? [] : searchable.filter((n) =>
n.data.label?.toLowerCase().includes(q) ||
n.data.ip?.toLowerCase().includes(q) ||
n.data.hostname?.toLowerCase().includes(q)
).slice(0, 8)
const handleSelect = useCallback((nodeId: string) => {
setSelectedNode(nodeId)
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
onClose()
setQuery('')
}, [fitView, setSelectedNode, onClose])
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" onClick={onClose}>
<div
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-md"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<Search size={16} className="text-muted-foreground shrink-0" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search nodes by label, IP, hostname…"
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
onKeyDown={(e) => {
if (e.key === 'Escape') { onClose(); setQuery('') }
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
}}
/>
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
</div>
{results.length > 0 && (
<ul className="py-1 max-h-64 overflow-y-auto">
{results.map((node) => (
<li
key={node.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelect(node.id)}
>
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
{node.data.ip && (
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
)}
</li>
))}
</ul>
)}
{q.length > 0 && results.length === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
)}
{q.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,84 @@
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
const SHORTCUTS = [
{
group: 'Canvas',
items: [
{ keys: ['Ctrl', 'S'], description: 'Save canvas' },
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
{ keys: ['Ctrl', 'K'], description: 'Search nodes' },
{ keys: ['?'], description: 'Show this help' },
],
},
{
group: 'Nodes',
items: [
{ keys: ['Ctrl', 'C'], description: 'Copy selected nodes' },
{ keys: ['Ctrl', 'V'], description: 'Paste nodes' },
{ keys: ['Del'], description: 'Delete selected node/edge' },
],
},
{
group: 'Navigation',
items: [
{ keys: ['Scroll'], description: 'Zoom in / out' },
{ keys: ['Space', '+', 'Drag'], description: 'Pan canvas' },
{ keys: ['Ctrl', 'Shift', 'F'], description: 'Fit view' },
],
},
]
interface ShortcutsModalProps {
open: boolean
onClose: () => void
}
export function ShortcutsModal({ open, onClose }: ShortcutsModalProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-sm"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onClose}>
<X size={14} />
</Button>
</div>
<div className="p-4 space-y-4">
{SHORTCUTS.map((group) => (
<div key={group.group}>
<p className="text-xs text-[#00d4ff] font-semibold mb-2 uppercase tracking-wide">
{group.group}
</p>
<div className="space-y-1.5">
{group.items.map((item) => (
<div key={item.description} className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{item.description}</span>
<div className="flex items-center gap-1 shrink-0">
{item.keys.map((k, i) => (
k === '+' ? (
<span key={i} className="text-xs text-muted-foreground">+</span>
) : (
<kbd key={k} className="text-[11px] text-foreground border border-border rounded px-1.5 py-0.5 font-mono bg-[#0d1117]">
{k}
</kbd>
)
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,167 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Check } 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'
// Node-type accent colors to display as preview swatches
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
interface ThemeCardProps {
themeId: ThemeId
selected: boolean
onClick: () => void
}
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
const preset = THEMES[themeId]
const c = preset.colors
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}` }}
>
{/* 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 */}
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 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>
)
}
interface ThemeModalProps {
open: boolean
onClose: () => void
}
export function ThemeModal({ open, onClose }: ThemeModalProps) {
const { activeTheme, setTheme } = useThemeStore()
const { markUnsaved } = useCanvasStore()
// Capture the theme that was active when the modal opened
const [originalTheme] = useState<ThemeId>(activeTheme)
const [selected, setSelected] = useState<ThemeId>(activeTheme)
const handleSelect = (id: ThemeId) => {
setSelected(id)
// Live-preview the selected theme on the canvas
setTheme(id)
}
const handleApply = () => {
setTheme(selected)
markUnsaved()
onClose()
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>
<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 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>
)
}
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { NodeModal } from '../NodeModal'
describe('NodeModal', () => {
it('renders nothing when closed', () => {
const { container } = render(
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form fields when open', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
expect(screen.getByText('Add Node')).toBeDefined()
})
it('does not call onSubmit when label is empty and shows error', () => {
const onSubmit = vi.fn()
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).not.toHaveBeenCalled()
expect(screen.getByText('Label is required')).toBeDefined()
})
it('calls onSubmit with form data when label is filled', () => {
const onSubmit = vi.fn()
const onClose = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
expect(onClose).toHaveBeenCalledOnce()
})
it('clears label error when user starts typing', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Add'))
expect(screen.getByText('Label is required')).toBeDefined()
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
expect(screen.queryByText('Label is required')).toBeNull()
})
it('pre-fills form from initial prop', () => {
render(
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
)
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
expect(input.value).toBe('Pre-filled')
})
it('shows Save button text when title is Edit Node', () => {
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
expect(screen.getByText('Save')).toBeDefined()
})
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { ShortcutsModal } from '../ShortcutsModal'
describe('ShortcutsModal', () => {
it('renders nothing when closed', () => {
const { container } = render(<ShortcutsModal open={false} onClose={vi.fn()} />)
expect(container.firstChild).toBeNull()
})
it('renders shortcut groups when open', () => {
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
expect(screen.getByText('Keyboard Shortcuts')).toBeDefined()
expect(screen.getByText('Canvas')).toBeDefined()
expect(screen.getByText('Nodes')).toBeDefined()
expect(screen.getByText('Navigation')).toBeDefined()
})
it('shows key shortcuts in kbd elements', () => {
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
})
it('calls onClose when backdrop clicked', () => {
const onClose = vi.fn()
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
fireEvent.click(container.firstChild as HTMLElement)
expect(onClose).toHaveBeenCalled()
})
it('calls onClose when X button clicked', () => {
const onClose = vi.fn()
render(<ShortcutsModal open={true} onClose={onClose} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(onClose).toHaveBeenCalled()
})
})
+15 -1
View File
@@ -76,7 +76,21 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{/* Details */}
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
{data.hostname && <DetailRow label="Hostname" value={data.hostname} mono />}
{data.hostname && (
<div className="flex justify-between gap-2 items-baseline">
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
<a
href={`http://${data.hostname}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
title={data.hostname}
>
{data.hostname}
<ExternalLink size={10} className="shrink-0" />
</a>
</div>
)}
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
{data.os && <DetailRow label="OS" value={data.os} />}
+12 -9
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client'
@@ -39,7 +40,7 @@ interface SidebarProps {
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>('canvas')
const { nodes, hasUnsavedChanges } = useCanvasStore()
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
@@ -70,13 +71,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
</button>
{/* Logo */}
<div className="flex items-center gap-2 px-3 py-4 border-b border-border">
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[#00d4ff]/10 text-[#00d4ff] shrink-0">
<Network size={16} />
</div>
{!collapsed && (
<span className="font-semibold text-sm tracking-wide text-foreground">Homelable</span>
)}
<div className="flex items-center px-3 py-4 border-b border-border overflow-hidden">
<Logo size={28} showText={!collapsed} />
</div>
{/* Views */}
@@ -130,6 +126,13 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={Square} label="Add Rectangle" collapsed={collapsed} onClick={onAddGroupRect} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
<SidebarItem
icon={hideIp ? EyeOff : Eye}
label={hideIp ? 'Show IPs' : 'Hide IPs'}
collapsed={collapsed}
onClick={toggleHideIp}
active={hideIp}
/>
<SidebarItem
icon={Save}
label="Save Canvas"
+39 -4
View File
@@ -1,25 +1,60 @@
import { Save, LayoutDashboard, Download } from 'lucide-react'
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore'
interface ToolbarProps {
onSave: () => void
onAutoLayout: () => void
onExport: () => void
onChangeStyle: () => void
onUndo: () => void
onRedo: () => void
onShortcuts: () => void
onExportMd: () => void
}
export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) {
const { hasUnsavedChanges } = useCanvasStore()
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
const { hasUnsavedChanges, past, future } = useCanvasStore()
return (
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
<Logo size={28} showText={true} />
<div className="flex-1" />
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
onClick={onUndo}
disabled={past.length === 0}
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</Button>
<Button
size="sm" variant="ghost"
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
onClick={onRedo}
disabled={future.length === 0}
title="Redo (Ctrl+Y)"
>
<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}>
<LayoutDashboard size={14} /> Auto Layout
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
<Download size={14} /> Export
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" 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 (?)">
<HelpCircle size={14} />
</Button>
<Button
size="sm"
className="gap-1.5 relative"
+46
View File
@@ -0,0 +1,46 @@
interface LogoProps {
size?: number;
showText?: boolean;
className?: string;
}
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="32" cy="32" r="32" fill="#0d1117" />
<path
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
fill="#161b22"
stroke="#00d4ff"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
<circle cx="22" cy="38" r="2" fill="#39d353" />
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="42" cy="38" r="2" fill="#39d353" />
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="24" r="2" fill="#a855f7" />
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
</svg>
{showText && (
<span
className="font-bold tracking-tight"
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
>
<span style={{ color: '#00d4ff' }}>Home</span>
<span style={{ color: '#ffffff' }}>lable</span>
</span>
)}
</div>
);
}
+11 -1
View File
@@ -90,7 +90,7 @@
/* React Flow overrides */
.react-flow__background {
background-color: var(--surface-base) !important;
background-color: transparent;
}
.react-flow__minimap {
background-color: var(--surface-elevated) !important;
@@ -115,3 +115,13 @@
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Edge flow animation — dot traveling from source to target */
@keyframes flow-dot {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: -10000; }
}
.edge-flow-dot {
animation: flow-dot 2.5s linear infinite;
pointer-events: none;
}
@@ -26,6 +26,9 @@ describe('canvasStore', () => {
hasUnsavedChanges: false,
selectedNodeId: null,
editingGroupRectId: null,
past: [],
future: [],
clipboard: [],
})
})
@@ -234,4 +237,95 @@ describe('canvasStore', () => {
const childIdx = nodes.findIndex((n) => n.id === 'c1')
expect(parentIdx).toBeLessThan(childIdx)
})
// --- History (undo/redo) ---
it('snapshotHistory pushes current state to past and clears future', () => {
const { addNode, snapshotHistory } = useCanvasStore.getState()
addNode(makeNode('n1'))
snapshotHistory()
const { past, future } = useCanvasStore.getState()
expect(past).toHaveLength(1)
expect(past[0].nodes).toHaveLength(1)
expect(future).toHaveLength(0)
})
it('undo restores previous state and moves current to future', () => {
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
addNode(makeNode('n1'))
snapshotHistory()
addNode(makeNode('n2'))
undo()
const { nodes, past, future } = useCanvasStore.getState()
expect(nodes).toHaveLength(1)
expect(nodes[0].id).toBe('n1')
expect(past).toHaveLength(0)
expect(future).toHaveLength(1)
})
it('redo re-applies undone state', () => {
const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState()
addNode(makeNode('n1'))
snapshotHistory()
addNode(makeNode('n2'))
undo()
redo()
const { nodes, future } = useCanvasStore.getState()
expect(nodes).toHaveLength(2)
expect(future).toHaveLength(0)
})
it('undo does nothing when past is empty', () => {
const { addNode, undo } = useCanvasStore.getState()
addNode(makeNode('n1'))
undo()
expect(useCanvasStore.getState().nodes).toHaveLength(1)
})
it('snapshotHistory clears future (new branch)', () => {
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
addNode(makeNode('n1'))
snapshotHistory()
addNode(makeNode('n2'))
undo()
// now take a new action
snapshotHistory()
addNode(makeNode('n3'))
expect(useCanvasStore.getState().future).toHaveLength(0)
})
// --- Clipboard (copy/paste) ---
it('copySelectedNodes stores only selected nodes', () => {
useCanvasStore.setState({
nodes: [
{ ...makeNode('a'), selected: true },
{ ...makeNode('b'), selected: false },
],
edges: [],
})
useCanvasStore.getState().copySelectedNodes()
const { clipboard } = useCanvasStore.getState()
expect(clipboard).toHaveLength(1)
expect(clipboard[0].id).toBe('a')
})
it('pasteNodes creates new nodes with new IDs and offset position', () => {
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
useCanvasStore.getState().pasteNodes()
const { nodes } = useCanvasStore.getState()
expect(nodes).toHaveLength(2)
const pasted = nodes.find((n) => n.id !== 'src')!
expect(pasted).toBeDefined()
expect(pasted.position.x).toBe(150)
expect(pasted.position.y).toBe(150)
expect(pasted.selected).toBe(false)
})
it('pasteNodes does nothing when clipboard is empty', () => {
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
useCanvasStore.getState().pasteNodes()
expect(useCanvasStore.getState().nodes).toHaveLength(1)
})
})
@@ -0,0 +1,31 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useThemeStore } from '@/stores/themeStore'
describe('themeStore', () => {
beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' })
})
it('starts with default theme', () => {
expect(useThemeStore.getState().activeTheme).toBe('default')
})
it('setTheme updates activeTheme', () => {
useThemeStore.getState().setTheme('matrix')
expect(useThemeStore.getState().activeTheme).toBe('matrix')
})
it('setTheme can switch between all presets', () => {
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
for (const id of themes) {
useThemeStore.getState().setTheme(id)
expect(useThemeStore.getState().activeTheme).toBe(id)
}
})
it('setTheme back to default after neon', () => {
useThemeStore.getState().setTheme('neon')
useThemeStore.getState().setTheme('default')
expect(useThemeStore.getState().activeTheme).toBe('default')
})
})
+83
View File
@@ -11,6 +11,8 @@ import {
} from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
interface CanvasState {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
@@ -18,6 +20,18 @@ interface CanvasState {
selectedNodeId: string | null
scanEventTs: number
// History
past: HistoryEntry[]
future: HistoryEntry[]
snapshotHistory: () => void
undo: () => void
redo: () => void
// Clipboard
clipboard: Node<NodeData>[]
copySelectedNodes: () => void
pasteNodes: () => void
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
onConnect: (connection: Connection) => void
@@ -32,8 +46,11 @@ interface CanvasState {
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
notifyScanDeviceFound: () => void
hideIp: boolean
toggleHideIp: () => void
}
export const useCanvasStore = create<CanvasState>((set) => ({
@@ -42,8 +59,70 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hasUnsavedChanges: false,
selectedNodeId: null,
editingGroupRectId: null,
hideIp: false,
scanEventTs: 0,
past: [],
future: [],
clipboard: [],
snapshotHistory: () =>
set((state) => ({
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
})),
undo: () =>
set((state) => {
if (state.past.length === 0) return state
const previous = state.past[state.past.length - 1]
return {
nodes: previous.nodes,
edges: previous.edges,
past: state.past.slice(0, -1),
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
hasUnsavedChanges: true,
}
}),
redo: () =>
set((state) => {
if (state.future.length === 0) return state
const next = state.future[0]
return {
nodes: next.nodes,
edges: next.edges,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: state.future.slice(1),
hasUnsavedChanges: true,
}
}),
copySelectedNodes: () =>
set((state) => ({
clipboard: state.nodes.filter((n) => n.selected),
})),
pasteNodes: () =>
set((state) => {
if (state.clipboard.length === 0) return state
const newNodes = state.clipboard.map((n) => ({
...n,
id: crypto.randomUUID(),
position: { x: n.position.x + 50, y: n.position.y + 50 },
selected: false,
parentId: undefined,
extent: undefined,
data: { ...n.data, parent_id: undefined },
}))
return {
nodes: [...state.nodes, ...newNodes],
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
hasUnsavedChanges: true,
}
}),
onNodesChange: (changes) =>
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
@@ -155,8 +234,12 @@ export const useCanvasStore = create<CanvasState>((set) => ({
markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }),
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
loadCanvas: (nodes, edges) => {
// React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId)
+12
View File
@@ -0,0 +1,12 @@
import { create } from 'zustand'
import type { ThemeId } from '@/utils/themes'
interface ThemeState {
activeTheme: ThemeId
setTheme: (id: ThemeId) => void
}
export const useThemeStore = create<ThemeState>((set) => ({
activeTheme: 'default',
setTheme: (id) => set({ activeTheme: id }),
}))
+1
View File
@@ -81,6 +81,7 @@ export interface EdgeData extends Record<string, unknown> {
speed?: string
custom_color?: string
path_style?: EdgePathStyle
animated?: boolean
}
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { generateMarkdownTable } from '../exportMarkdown'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const makeNode = (overrides: Partial<NodeData> = {}, id = '1'): Node<NodeData> => ({
id,
type: overrides.type ?? 'server',
position: { x: 0, y: 0 },
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
})
describe('generateMarkdownTable', () => {
it('returns empty string for empty node list', () => {
expect(generateMarkdownTable([])).toBe('')
})
it('excludes groupRect nodes', () => {
const nodes = [makeNode({ type: 'groupRect', label: 'Zone' })]
expect(generateMarkdownTable(nodes)).toBe('')
})
it('generates header + separator + row', () => {
const nodes = [makeNode({ label: 'Router', type: 'router', ip: '192.168.1.1', status: 'online' })]
const md = generateMarkdownTable(nodes)
const lines = md.split('\n')
expect(lines[0]).toContain('Label')
expect(lines[0]).toContain('IP')
expect(lines[1]).toContain('---')
expect(lines[2]).toContain('Router')
expect(lines[2]).toContain('192.168.1.1')
})
it('uses — for missing fields', () => {
const nodes = [makeNode({ label: 'Node', type: 'generic', ip: undefined, hostname: undefined })]
const md = generateMarkdownTable(nodes)
expect(md).toContain('—')
})
it('lists services as name:port pairs', () => {
const nodes = [makeNode({
label: 'Server',
services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }, { port: 443, protocol: 'tcp', service_name: 'https' }],
})]
const md = generateMarkdownTable(nodes)
expect(md).toContain('nginx:80')
expect(md).toContain('https:443')
})
it('escapes pipe characters in cell values', () => {
const nodes = [makeNode({ label: 'A|B' })]
const md = generateMarkdownTable(nodes)
expect(md).toContain('A\\|B')
})
it('generates one row per non-groupRect node', () => {
const nodes = [
makeNode({ type: 'server', label: 'A' }, '1'),
makeNode({ type: 'router', label: 'B' }, '2'),
makeNode({ type: 'groupRect', label: 'Zone' }, '3'),
]
const lines = generateMarkdownTable(nodes).split('\n')
// header + separator + 2 data rows
expect(lines).toHaveLength(4)
})
})
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import { applyDagreLayout } from '../layout'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
function makeNode(id: string, type: string, parentId?: string): Node<NodeData> {
return {
id,
type,
position: { x: 0, y: 0 },
data: { type, label: id } as unknown as NodeData,
...(parentId ? { parentId } : {}),
}
}
function makeEdge(source: string, target: string, sourceHandle?: string): Edge<EdgeData> {
return { id: `${source}-${target}`, source, target, sourceHandle, data: {} as EdgeData }
}
describe('applyDagreLayout', () => {
it('places two proxmox nodes connected to each other at the same Y', () => {
const nodes = [
makeNode('router', 'router'),
makeNode('pve1', 'proxmox'),
makeNode('pve2', 'proxmox'),
]
const edges = [
makeEdge('router', 'pve1'),
makeEdge('router', 'pve2'),
makeEdge('pve1', 'pve2'),
]
const result = applyDagreLayout(nodes, edges)
const pve1 = result.find((n) => n.id === 'pve1')!
const pve2 = result.find((n) => n.id === 'pve2')!
expect(pve1.position.y).toBe(pve2.position.y)
})
it('orders peer nodes left-to-right by chain: endpoint first, middle last', () => {
// pve-left -- pve-center -- pve-right (chain)
// router connects to all three
const nodes = [
makeNode('router', 'router'),
makeNode('pve-left', 'proxmox'),
makeNode('pve-center', 'proxmox'),
makeNode('pve-right', 'proxmox'),
]
const edges = [
makeEdge('router', 'pve-left'),
makeEdge('router', 'pve-center'),
makeEdge('router', 'pve-right'),
makeEdge('pve-left', 'pve-center'),
makeEdge('pve-center', 'pve-right'),
]
const result = applyDagreLayout(nodes, edges)
const left = result.find((n) => n.id === 'pve-left')!
const center = result.find((n) => n.id === 'pve-center')!
const right = result.find((n) => n.id === 'pve-right')!
// All at same Y
expect(left.position.y).toBe(center.position.y)
expect(center.position.y).toBe(right.position.y)
// X order: endpoint (left or right) < center (middle has 2 peer connections)
// The BFS starts from an endpoint, so we just verify the middle is not at the extremes
const xs = [left.position.x, center.position.x, right.position.x].sort((a, b) => a - b)
expect(center.position.x).toBe(xs[1]) // pve-center must be in the middle
})
it('keeps child nodes (parentId set) in place', () => {
const nodes = [
makeNode('router', 'router'),
makeNode('pve1', 'proxmox'),
makeNode('vm1', 'vm', 'pve1'),
]
const edges = [makeEdge('router', 'pve1')]
const result = applyDagreLayout(nodes, edges)
const vm1 = result.find((n) => n.id === 'vm1')!
expect(vm1.position).toEqual({ x: 0, y: 0 })
})
it('places a node below its parent when the edge exits from the top handle (upward edge)', () => {
// Frigate connects UP to Proxmox via its top handle (source=Frigate, sourceHandle='top')
// Dagre must place Frigate BELOW Proxmox, not above.
const nodes = [
makeNode('router', 'router'),
makeNode('proxmox', 'proxmox'),
makeNode('frigate', 'server'),
]
const edges = [
makeEdge('router', 'proxmox'),
makeEdge('frigate', 'proxmox', 'top'), // upward edge: frigate → proxmox via top handle
]
const result = applyDagreLayout(nodes, edges)
const proxmox = result.find((n) => n.id === 'proxmox')!
const frigate = result.find((n) => n.id === 'frigate')!
expect(frigate.position.y).toBeGreaterThan(proxmox.position.y)
})
it('places two switch nodes connected to each other at the same Y', () => {
const nodes = [
makeNode('router', 'router'),
makeNode('sw1', 'switch'),
makeNode('sw2', 'switch'),
]
const edges = [
makeEdge('router', 'sw1'),
makeEdge('router', 'sw2'),
makeEdge('sw1', 'sw2'),
]
const result = applyDagreLayout(nodes, edges)
const sw1 = result.find((n) => n.id === 'sw1')!
const sw2 = result.find((n) => n.id === 'sw2')!
expect(sw1.position.y).toBe(sw2.position.y)
})
})
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { maskIp } from '../maskIp'
describe('maskIp', () => {
it('masks last two octets of a standard IPv4', () => {
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
})
it('masks any IPv4', () => {
expect(maskIp('10.0.0.1')).toBe('10.0.XX.XX')
expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX')
})
it('passes through non-IPv4 strings unchanged', () => {
expect(maskIp('hostname')).toBe('hostname')
expect(maskIp('fe80::1')).toBe('fe80::1')
expect(maskIp('')).toBe('')
})
})
@@ -52,4 +52,25 @@ describe('resolveNodeColors', () => {
const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined })
expect(result).toEqual(NODE_DEFAULT_COLORS.generic)
})
it('uses theme colors when themeId is provided', () => {
const result = resolveNodeColors(makeData({ type: 'server' }), 'matrix')
expect(result.border).not.toBe(NODE_DEFAULT_COLORS.server.border)
expect(result.border).toBe('#008822') // matrix server accent
})
it('custom_colors override theme colors', () => {
const custom = { border: '#ff0000' }
const result = resolveNodeColors(makeData({ type: 'server', custom_colors: custom }), 'neon')
expect(result.border).toBe('#ff0000')
// background should come from neon theme, not default
expect(result.background).not.toBe(NODE_DEFAULT_COLORS.server.background)
})
it('groupRect always has transparent background regardless of theme', () => {
for (const themeId of ['default', 'light', 'matrix', 'neon', 'dark'] as const) {
const result = resolveNodeColors(makeData({ type: 'groupRect' }), themeId)
expect(result.background).toBe('transparent')
}
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
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', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
describe('THEME_ORDER', () => {
it('contains all theme IDs', () => {
const keys = Object.keys(THEMES) as ThemeId[]
expect(THEME_ORDER).toHaveLength(keys.length)
expect(new Set(THEME_ORDER)).toEqual(new Set(keys))
})
})
describe('THEMES', () => {
for (const themeId of Object.keys(THEMES) as ThemeId[]) {
describe(`theme: ${themeId}`, () => {
const preset = THEMES[themeId]
it('has id, label, description', () => {
expect(preset.id).toBe(themeId)
expect(typeof preset.label).toBe('string')
expect(preset.label.length).toBeGreaterThan(0)
expect(typeof preset.description).toBe('string')
})
it('has nodeAccents for all node types', () => {
for (const type of NODE_TYPES) {
expect(preset.colors.nodeAccents[type]).toBeDefined()
expect(typeof preset.colors.nodeAccents[type].border).toBe('string')
expect(typeof preset.colors.nodeAccents[type].icon).toBe('string')
}
})
it('has surface colors', () => {
expect(typeof preset.colors.nodeCardBackground).toBe('string')
expect(typeof preset.colors.nodeIconBackground).toBe('string')
expect(typeof preset.colors.nodeLabelColor).toBe('string')
expect(typeof preset.colors.nodeSubtextColor).toBe('string')
})
it('has statusColors for all statuses', () => {
for (const status of STATUS_TYPES) {
expect(typeof preset.colors.statusColors[status]).toBe('string')
}
})
it('has edgeColors for all edge types', () => {
for (const type of EDGE_TYPES) {
expect(typeof preset.colors.edgeColors[type]).toBe('string')
}
})
it('has edge label colors', () => {
expect(typeof preset.colors.edgeSelectedColor).toBe('string')
expect(typeof preset.colors.edgeLabelBackground).toBe('string')
expect(typeof preset.colors.edgeLabelColor).toBe('string')
expect(typeof preset.colors.edgeLabelBorder).toBe('string')
})
it('has canvas colors', () => {
expect(typeof preset.colors.canvasBackground).toBe('string')
expect(typeof preset.colors.canvasDotColor).toBe('string')
})
it('has handle colors', () => {
expect(typeof preset.colors.handleBackground).toBe('string')
expect(typeof preset.colors.handleBorder).toBe('string')
})
it('has valid reactFlowColorMode', () => {
expect(['dark', 'light']).toContain(preset.colors.reactFlowColorMode)
})
})
}
it('default theme matches original hardcoded colors', () => {
const d = THEMES.default.colors
expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117')
expect(d.canvasDotColor).toBe('#30363d')
expect(d.edgeColors.ethernet).toBe('#30363d')
expect(d.edgeColors.wifi).toBe('#00d4ff')
expect(d.statusColors.online).toBe('#39d353')
expect(d.statusColors.offline).toBe('#f85149')
})
it('light theme has reactFlowColorMode light', () => {
expect(THEMES.light.colors.reactFlowColorMode).toBe('light')
})
it('light theme has light canvas background', () => {
expect(THEMES.light.colors.canvasBackground).toBe('#f6f8fa')
})
it('matrix theme uses green accents', () => {
const m = THEMES.matrix.colors
expect(m.nodeAccents.isp.border).toMatch(/^#0/)
expect(m.nodeLabelColor).toBe('#00ff41')
expect(m.canvasBackground).toBe('#000000')
})
})
+42
View File
@@ -0,0 +1,42 @@
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const EMPTY = '—'
function cell(v: string | null | undefined): string {
if (!v) return EMPTY
// Escape pipe chars so they don't break the table
return v.replace(/\|/g, '\\|')
}
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
const rows = nodes
.filter((n) => n.data.type !== 'groupRect')
.map((n) => {
const d = n.data
const services = d.services?.length
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
: EMPTY
return [
cell(d.label),
cell(d.type),
cell(d.ip),
cell(d.hostname),
cell(d.status),
services,
]
})
if (rows.length === 0) return ''
const headers = ['Label', 'Type', 'IP', 'Hostname', 'Status', 'Services']
const separator = headers.map(() => '---')
const lines = [
`| ${headers.join(' | ')} |`,
`| ${separator.join(' | ')} |`,
...rows.map((r) => `| ${r.join(' | ')} |`),
]
return lines.join('\n')
}
+117 -14
View File
@@ -5,46 +5,149 @@ import type { NodeData, EdgeData } from '@/types'
const NODE_WIDTH = 180
const NODE_HEIGHT = 52
const PEER_TYPES = new Set(['proxmox', 'switch'])
/**
* Find groups of peer nodes (same type, directly connected to each other)
* using union-find. Returns a map: nodeId → groupId (the minimum nodeId in the group).
*/
function buildPeerGroups(
topLevel: Node<NodeData>[],
edges: Edge<EdgeData>[],
): Map<string, string> {
const parent = new Map<string, string>(topLevel.map((n) => [n.id, n.id]))
function find(id: string): string {
if (parent.get(id) !== id) parent.set(id, find(parent.get(id)!))
return parent.get(id)!
}
function union(a: string, b: string) {
const ra = find(a), rb = find(b)
if (ra !== rb) parent.set(ra, rb)
}
const topLevelIds = new Set(topLevel.map((n) => n.id))
const peerIds = new Set(topLevel.filter((n) => PEER_TYPES.has(n.type ?? '')).map((n) => n.id))
for (const edge of edges) {
const { source: s, target: t } = edge
if (topLevelIds.has(s) && topLevelIds.has(t) && peerIds.has(s) && peerIds.has(t)) {
// Only merge if both nodes share the same type (proxmox↔proxmox, switch↔switch)
const srcNode = topLevel.find((n) => n.id === s)!
const tgtNode = topLevel.find((n) => n.id === t)!
if (srcNode.type === tgtNode.type) union(s, t)
}
}
// Resolve all to canonical group ids
const result = new Map<string, string>()
for (const n of topLevel) result.set(n.id, find(n.id))
return result
}
/**
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
* Child nodes (parentId set) keep their relative position inside the parent — only
* top-level nodes are repositioned by Dagre.
*
* Post-pass: peer nodes of the same type (proxmox, switch) connected to each other
* are snapped to the same Y rank so they appear on the same horizontal level.
*/
export function applyDagreLayout(
nodes: Node<NodeData>[],
edges: Edge<EdgeData>[],
): Node<NodeData>[] {
const topLevel = nodes.filter((n) => !n.parentId)
const topLevelIds = new Set(topLevel.map((n) => n.id))
// Capture original X positions before Dagre — used to preserve left-to-right
// ordering of peer groups as the user set them.
const originalX = new Map<string, number>(topLevel.map((n) => [n.id, n.position.x]))
// Identify peer groups before running Dagre so we can exclude peer edges
const peerGroups = buildPeerGroups(topLevel, edges)
const isPeerEdge = (e: Edge<EdgeData>) => {
const sg = peerGroups.get(e.source)
const tg = peerGroups.get(e.target)
return sg !== undefined && tg !== undefined && sg === tg
}
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
const topLevel = nodes.filter((n) => !n.parentId)
for (const node of topLevel) {
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
g.setNode(node.id, { width: w, height: h })
}
for (const edge of edges) {
// Only add edges between top-level nodes
const srcTop = topLevel.some((n) => n.id === edge.source)
const tgtTop = topLevel.some((n) => n.id === edge.target)
if (srcTop && tgtTop) g.setEdge(edge.source, edge.target)
const srcTop = topLevelIds.has(edge.source)
const tgtTop = topLevelIds.has(edge.target)
// Exclude peer-to-peer edges — they confuse Dagre's rank assignment
if (!srcTop || !tgtTop || isPeerEdge(edge)) continue
// If the edge exits from the TOP handle of the source, the connection goes
// upward — meaning the source node is visually below the target. Reverse
// the edge direction for Dagre so it places the source below the target.
const upward = (edge as { sourceHandle?: string | null }).sourceHandle === 'top'
if (upward) {
g.setEdge(edge.target, edge.source)
} else {
g.setEdge(edge.source, edge.target)
}
}
dagre.layout(g)
return nodes.map((node) => {
if (node.parentId) return node // keep children in place
// Build initial positions from Dagre
const positions = new Map<string, { x: number; y: number; w: number; h: number }>()
for (const node of topLevel) {
const pos = g.node(node.id)
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
return {
...node,
position: {
x: pos.x - w / 2,
y: pos.y - h / 2,
},
positions.set(node.id, { x: pos.x - w / 2, y: pos.y - h / 2, w, h })
}
// Post-pass: fix peer groups (same-type nodes directly connected to each other)
// Collect members per group
const groupMembers = new Map<string, string[]>()
for (const [id, groupId] of peerGroups) {
if (!groupMembers.has(groupId)) groupMembers.set(groupId, [])
groupMembers.get(groupId)!.push(id)
}
for (const [, members] of groupMembers) {
if (members.length < 2) continue
// --- Y: snap all to average Y of the group ---
const avgY = members.reduce((sum, id) => sum + positions.get(id)!.y, 0) / members.length
for (const id of members) positions.set(id, { ...positions.get(id)!, y: avgY })
// --- X: sort by original (pre-layout) X to preserve the user's intended
// left-to-right order. Fall back to Dagre X if all nodes share the
// same original X (e.g. freshly created canvas with no positions yet). ---
const GAP = 60
const origXs = members.map((id) => originalX.get(id) ?? 0)
const allSameOrigX = origXs.every((x) => x === origXs[0])
const ordered = members.slice().sort((a, b) =>
allSameOrigX
? positions.get(a)!.x - positions.get(b)!.x // fall back to Dagre X
: (originalX.get(a) ?? 0) - (originalX.get(b) ?? 0), // preserve user order
)
const totalWidth = ordered.reduce((sum, id) => sum + positions.get(id)!.w, 0) + GAP * (ordered.length - 1)
const centerX = members.reduce((sum, id) => sum + positions.get(id)!.x + positions.get(id)!.w / 2, 0) / members.length
let curX = centerX - totalWidth / 2
for (const id of ordered) {
const p = positions.get(id)!
positions.set(id, { ...p, x: curX })
curX += p.w + GAP
}
}
return nodes.map((node) => {
if (node.parentId) return node
const p = positions.get(node.id)!
return { ...node, position: { x: p.x, y: p.y } }
})
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Mask the last two octets of an IPv4 address.
* e.g. "192.168.1.115" → "192.168.XX.XX"
* Non-IPv4 strings are returned unchanged.
*/
export function maskIp(ip: string): string {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
return ip
}
+25 -19
View File
@@ -1,4 +1,5 @@
import type { NodeData, NodeType } from '@/types'
import { THEMES, type ThemeId } from './themes'
export interface NodeColors {
border: string
@@ -6,26 +7,31 @@ export interface NodeColors {
icon: string
}
export const NODE_DEFAULT_COLORS: Record<NodeType, NodeColors> = {
isp: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
router: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
switch: { border: '#39d353', background: '#21262d', icon: '#39d353' },
server: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
proxmox: { border: '#ff6e00', background: '#21262d', icon: '#ff6e00' },
vm: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
lxc: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
nas: { border: '#39d353', background: '#21262d', icon: '#39d353' },
iot: { border: '#e3b341', background: '#21262d', icon: '#e3b341' },
ap: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' },
camera: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
printer: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
computer: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
cpl: { border: '#e3b341', background: '#21262d', icon: '#e3b341' },
generic: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
}
// Derived from the default theme — kept for backward compatibility and tests
export const NODE_DEFAULT_COLORS: Record<NodeType, NodeColors> = Object.fromEntries(
(Object.entries(THEMES.default.colors.nodeAccents) as [NodeType, { border: string; icon: string }][]).map(
([type, accent]) => [
type,
{
border: accent.border,
background: type === 'groupRect' ? 'transparent' : THEMES.default.colors.nodeCardBackground,
icon: accent.icon,
},
]
)
) as Record<NodeType, NodeColors>
export function resolveNodeColors(data: Pick<NodeData, 'type' | 'custom_colors'>): NodeColors {
const defaults = NODE_DEFAULT_COLORS[data.type] ?? NODE_DEFAULT_COLORS.generic
export function resolveNodeColors(
data: Pick<NodeData, 'type' | 'custom_colors'>,
themeId: ThemeId = 'default',
): NodeColors {
const theme = THEMES[themeId] ?? THEMES.default
const accent = theme.colors.nodeAccents[data.type] ?? theme.colors.nodeAccents.generic
const defaults: NodeColors = {
border: accent.border,
background: data.type === 'groupRect' ? 'transparent' : theme.colors.nodeCardBackground,
icon: accent.icon,
}
const custom = data.custom_colors
return {
border: custom?.border ?? defaults.border,
+306
View File
@@ -0,0 +1,306 @@
import type { NodeType, EdgeType, NodeStatus } from '@/types'
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix'
export interface ThemeColors {
// Per node-type accent (border + icon)
nodeAccents: Record<NodeType, { border: string; icon: string }>
// Node surfaces
nodeCardBackground: string
nodeIconBackground: string
nodeLabelColor: string
nodeSubtextColor: string
// Status indicator dots
statusColors: Record<NodeStatus, string>
// Edges
edgeColors: Record<EdgeType, string>
edgeSelectedColor: string
edgeLabelBackground: string
edgeLabelColor: string
edgeLabelBorder: string
// Canvas
canvasBackground: string
canvasDotColor: string
// React Flow handles
handleBackground: string
handleBorder: string
// React Flow colorMode for built-in controls
reactFlowColorMode: 'dark' | 'light'
}
export interface ThemePreset {
id: ThemeId
label: string
description: string
colors: ThemeColors
}
export const THEMES: Record<ThemeId, ThemePreset> = {
default: {
id: 'default',
label: 'Default',
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' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ 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',
},
},
dark: {
id: 'dark',
label: 'Dark',
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' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
},
nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111',
nodeLabelColor: '#ffffff',
nodeSubtextColor: '#666666',
statusColors: {
online: '#4ade80',
offline: '#ef4444',
pending: '#fbbf24',
unknown: '#6b7280',
},
edgeColors: {
ethernet: '#1c1c1e',
wifi: '#22d3ee',
iot: '#fbbf24',
vlan: '#22d3ee',
virtual: '#6b7280',
cluster: '#fb923c',
},
edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111',
edgeLabelColor: '#666666',
edgeLabelBorder: '#1c1c1e',
canvasBackground: '#000000',
canvasDotColor: '#1a1a1a',
handleBackground: '#1c1c1e',
handleBorder: '#444444',
reactFlowColorMode: 'dark',
},
},
light: {
id: 'light',
label: 'Light',
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' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
},
nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff',
nodeLabelColor: '#1f2328',
nodeSubtextColor: '#57606a',
statusColors: {
online: '#16a34a',
offline: '#dc2626',
pending: '#d97706',
unknown: '#6b7280',
},
edgeColors: {
ethernet: '#d0d7de',
wifi: '#0284c7',
iot: '#d97706',
vlan: '#0284c7',
virtual: '#9ca3af',
cluster: '#ea580c',
},
edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff',
edgeLabelColor: '#57606a',
edgeLabelBorder: '#d0d7de',
canvasBackground: '#f6f8fa',
canvasDotColor: '#d0d7de',
handleBackground: '#d0d7de',
handleBorder: '#9ca3af',
reactFlowColorMode: 'light',
},
},
neon: {
id: 'neon',
label: 'Neon',
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' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
},
nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a',
nodeLabelColor: '#ffffff',
nodeSubtextColor: '#8888cc',
statusColors: {
online: '#00ff80',
offline: '#ff0040',
pending: '#ffff00',
unknown: '#8888cc',
},
edgeColors: {
ethernet: '#1a1a3a',
wifi: '#00ffff',
iot: '#ffff00',
vlan: '#00ffff',
virtual: '#8888cc',
cluster: '#ff8800',
},
edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a',
edgeLabelColor: '#8888cc',
edgeLabelBorder: '#1a1a3a',
canvasBackground: '#05050f',
canvasDotColor: '#1a1a3a',
handleBackground: '#1a1a3a',
handleBorder: '#8888cc',
reactFlowColorMode: 'dark',
},
},
matrix: {
id: 'matrix',
label: 'Matrix',
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' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
},
nodeCardBackground: '#001100',
nodeIconBackground: '#002200',
nodeLabelColor: '#00ff41',
nodeSubtextColor: '#006600',
statusColors: {
online: '#00ff41',
offline: '#ff0000',
pending: '#88ff00',
unknown: '#004400',
},
edgeColors: {
ethernet: '#003300',
wifi: '#00ff41',
iot: '#66ff33',
vlan: '#00cc33',
virtual: '#004400',
cluster: '#33ff66',
},
edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100',
edgeLabelColor: '#006600',
edgeLabelBorder: '#003300',
canvasBackground: '#000000',
canvasDotColor: '#002200',
handleBackground: '#003300',
handleBorder: '#006600',
reactFlowColorMode: 'dark',
},
},
}
// Ordered list for display in the modal
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix']
+12
View File
@@ -0,0 +1,12 @@
# MCP Server — copy to .env and fill in values
# Authenticates AI clients (Claude Code, Claude Desktop, etc.) → MCP server
# Generate: python3 -c "import secrets; print('mcp_sk_' + secrets.token_hex(24))"
MCP_API_KEY=mcp_sk_changeme
# Authenticates MCP server → backend (must match MCP_SERVICE_KEY in backend .env)
# Generate: python3 -c "import secrets; print('svc_' + secrets.token_hex(24))"
MCP_SERVICE_KEY=svc_changeme
# Backend URL — use http://backend:8000 in Docker, http://localhost:8000 for local dev
BACKEND_URL=http://localhost:8000
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
View File
+44
View File
@@ -0,0 +1,44 @@
import hmac
import json
from starlette.types import ASGIApp, Receive, Scope, Send
from .config import settings
_BYPASS_PATHS = {"/health", "/register"}
class ApiKeyMiddleware:
"""Pure ASGI middleware — compatible with SSE/streaming responses.
BaseHTTPMiddleware buffers the full response body and breaks SSE streams.
This implementation operates at the ASGI scope level and never touches
the response stream.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path: str = scope.get("path", "")
if path in _BYPASS_PATHS or path.startswith("/.well-known/"):
await self.app(scope, receive, send)
return
headers = dict(scope.get("headers", []))
key = headers.get(b"x-api-key", b"").decode()
expected = settings.mcp_api_key
if not key or not hmac.compare_digest(key.encode(), expected.encode()):
body = json.dumps({"detail": "Invalid or missing X-API-Key"}).encode()
await send({"type": "http.response.start", "status": 401,
"headers": [(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode())]})
await send({"type": "http.response.body", "body": body, "more_body": False})
return
await self.app(scope, receive, send)
+40
View File
@@ -0,0 +1,40 @@
import httpx
from .config import settings
class BackendClient:
def __init__(self):
self._client: httpx.AsyncClient | None = None
async def start(self):
self._client = httpx.AsyncClient(
base_url=settings.backend_url,
headers={"X-MCP-Service-Key": settings.mcp_service_key},
timeout=30.0,
)
async def stop(self):
if self._client:
await self._client.aclose()
async def request(self, method: str, path: str, **kwargs) -> dict:
resp = await self._client.request(method, path, **kwargs)
resp.raise_for_status()
if resp.status_code == 204:
return {}
return resp.json()
async def get(self, path: str) -> dict | list:
return await self.request("GET", path)
async def post(self, path: str, body: dict) -> dict:
return await self.request("POST", path, json=body)
async def patch(self, path: str, body: dict) -> dict:
return await self.request("PATCH", path, json=body)
async def delete(self, path: str) -> dict:
return await self.request("DELETE", path)
backend = BackendClient()
+12
View File
@@ -0,0 +1,12 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
mcp_api_key: str = "mcp_sk_changeme" # AI client → MCP server
mcp_service_key: str = "svc_changeme" # MCP server → backend
backend_url: str = "http://backend:8000"
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
+42
View File
@@ -0,0 +1,42 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from .auth import ApiKeyMiddleware
from .backend_client import backend
from .resources import register_resources
from .tools import register_tools
mcp_server = Server("homelable")
register_resources(mcp_server)
register_tools(mcp_server)
session_manager = StreamableHTTPSessionManager(
app=mcp_server,
json_response=False,
stateless=True,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
await backend.start()
async with session_manager.run():
yield
await backend.stop()
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
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"}
+43
View File
@@ -0,0 +1,43 @@
import json
from mcp.server import Server
from mcp.types import Resource, TextContent
from .backend_client import backend
RESOURCE_LIST = [
Resource(uri="homelable://canvas", name="Canvas", description="Full canvas state (nodes + edges + viewport)", mimeType="application/json"),
Resource(uri="homelable://nodes", name="Nodes", description="All nodes in the homelab", mimeType="application/json"),
Resource(uri="homelable://edges", name="Edges", description="All network edges/links", mimeType="application/json"),
Resource(uri="homelable://scan/pending", name="Pending devices", description="Discovered devices awaiting approval", mimeType="application/json"),
Resource(uri="homelable://scan/runs", name="Scan history", description="Recent scan run history", mimeType="application/json"),
]
ROUTES = {
"homelable://canvas": "/api/v1/canvas",
"homelable://nodes": "/api/v1/nodes",
"homelable://edges": "/api/v1/edges",
"homelable://scan/pending": "/api/v1/scan/pending",
"homelable://scan/runs": "/api/v1/scan/runs",
}
async def read_resource(uri: str) -> list[TextContent]:
if uri.startswith("homelable://nodes/") and uri != "homelable://nodes/":
node_id = uri.split("/")[-1]
data = await backend.get(f"/api/v1/nodes/{node_id}")
return [TextContent(type="text", text=json.dumps(data, indent=2))]
if uri not in ROUTES:
raise ValueError(f"Unknown resource URI: {uri}")
data = await backend.get(ROUTES[uri])
return [TextContent(type="text", text=json.dumps(data, indent=2))]
def register_resources(server: Server):
@server.list_resources()
async def _list():
return RESOURCE_LIST
@server.read_resource()
async def _read(uri: str):
return await read_resource(uri)
+154
View File
@@ -0,0 +1,154 @@
import json
from mcp.server import Server
from mcp.types import Tool, TextContent
from .backend_client import backend
def register_tools(server: Server):
@server.list_tools()
async def list_tools():
return [
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
"type": "object",
"required": ["type", "label"],
"properties": {
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
},
}),
Tool(name="update_node", description="Update an existing node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string"},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
},
}),
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
"label": {"type": "string"},
},
}),
Tool(name="delete_edge", description="Delete a network link", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
"type": "object",
"properties": {
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
},
}),
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
"label": {"type": "string"},
},
}),
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
result = await _dispatch(name, arguments)
return [TextContent(type="text", text=json.dumps(result, indent=2))]
def _slim_canvas(raw: dict) -> dict:
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
EDGE_KEEP = {"id", "source", "target", "type", "label"}
def slim_node(n: dict) -> dict:
data = n.get("data", {})
out = {k: v for k, v in data.items() if k in NODE_KEEP and v not in (None, "", [])}
out["id"] = n.get("id")
out["node_type"] = n.get("type")
return out
def slim_edge(e: dict) -> dict:
return {k: v for k, v in e.items() if k in EDGE_KEEP and v not in (None, "")}
return {
"nodes": [slim_node(n) for n in raw.get("nodes", [])],
"edges": [slim_edge(e) for e in raw.get("edges", [])],
}
async def _dispatch(name: str, args: dict) -> dict:
if name == "create_node":
return await backend.post("/api/v1/nodes", args)
if name == "update_node":
node_id = args.pop("id")
return await backend.patch(f"/api/v1/nodes/{node_id}", args)
if name == "delete_node":
return await backend.delete(f"/api/v1/nodes/{args['id']}")
if name == "create_edge":
return await backend.post("/api/v1/edges", args)
if name == "delete_edge":
return await backend.delete(f"/api/v1/edges/{args['id']}")
if name == "trigger_scan":
body = {"ranges": args["ranges"]} if "ranges" in args else {}
return await backend.post("/api/v1/scan/trigger", body)
if name == "approve_device":
device_id = args.pop("id")
return await backend.post(f"/api/v1/scan/pending/{device_id}/approve", args)
if name == "hide_device":
return await backend.post(f"/api/v1/scan/pending/{args['id']}/hide", {})
if name == "get_canvas":
raw = await backend.get("/api/v1/canvas")
return _slim_canvas(raw)
if name == "list_nodes":
return await backend.get("/api/v1/nodes")
if name == "list_pending_devices":
return await backend.get("/api/v1/scan/pending")
raise ValueError(f"Unknown tool: {name}")
+2
View File
@@ -0,0 +1,2 @@
[pytest]
pythonpath = .
+5
View File
@@ -0,0 +1,5 @@
mcp[cli]>=1.0
httpx>=0.27
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic-settings>=2.0
View File
+33
View File
@@ -0,0 +1,33 @@
import os
import pytest
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, ASGITransport
os.environ.setdefault("MCP_API_KEY", "test_key")
os.environ.setdefault("BACKEND_URL", "http://testbackend")
os.environ.setdefault("AUTH_USERNAME", "admin")
os.environ.setdefault("AUTH_PASSWORD", "admin")
from app.main import app # noqa: E402
@pytest.fixture
def api_key():
return "test_key"
@pytest.fixture
async def client(api_key):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
def mock_backend():
with patch("app.resources.backend") as mock_res, \
patch("app.tools.backend") as mock_tools:
mock_res.get = AsyncMock()
mock_tools.post = AsyncMock()
mock_tools.patch = AsyncMock()
mock_tools.delete = AsyncMock()
yield {"resources": mock_res, "tools": mock_tools}
+28
View File
@@ -0,0 +1,28 @@
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.anyio
async def test_health_no_key(client):
resp = await client.get("/health")
assert resp.status_code == 200
@pytest.mark.anyio
async def test_missing_api_key(client):
resp = await client.get("/mcp")
assert resp.status_code == 401
@pytest.mark.anyio
async def test_wrong_api_key(client):
resp = await client.get("/mcp", headers={"X-API-Key": "wrong"})
assert resp.status_code == 401
@pytest.mark.anyio
async def test_valid_api_key_passes(client, api_key):
# Auth passes — mock handle_request so we don't need a live MCP session
with patch("app.main.session_manager.handle_request", new_callable=AsyncMock):
resp = await client.get("/mcp", headers={"X-API-Key": api_key})
assert resp.status_code != 401
+47
View File
@@ -0,0 +1,47 @@
import pytest
from unittest.mock import AsyncMock, patch
from app.resources import read_resource
@pytest.fixture
def mock_backend():
with patch("app.resources.backend") as m:
m.get = AsyncMock(return_value={"data": "ok"})
yield m
@pytest.mark.anyio
async def test_read_canvas(mock_backend):
result = await read_resource("homelable://canvas")
mock_backend.get.assert_called_once_with("/api/v1/canvas")
assert len(result) == 1
@pytest.mark.anyio
async def test_read_nodes(mock_backend):
await read_resource("homelable://nodes")
mock_backend.get.assert_called_once_with("/api/v1/nodes")
@pytest.mark.anyio
async def test_read_edges(mock_backend):
await read_resource("homelable://edges")
mock_backend.get.assert_called_once_with("/api/v1/edges")
@pytest.mark.anyio
async def test_read_single_node(mock_backend):
await read_resource("homelable://nodes/abc123")
mock_backend.get.assert_called_once_with("/api/v1/nodes/abc123")
@pytest.mark.anyio
async def test_read_scan_pending(mock_backend):
await read_resource("homelable://scan/pending")
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
@pytest.mark.anyio
async def test_read_unknown_uri(mock_backend):
with pytest.raises(ValueError, match="Unknown resource URI"):
await read_resource("homelable://unknown")
+122
View File
@@ -0,0 +1,122 @@
import pytest
from unittest.mock import AsyncMock, patch
from app.tools import _dispatch
@pytest.fixture
def mock_backend():
with patch("app.tools.backend") as m:
m.post = AsyncMock(return_value={"id": "1"})
m.patch = AsyncMock(return_value={"id": "1"})
m.delete = AsyncMock(return_value={})
m.get = AsyncMock(return_value=[])
yield m
@pytest.mark.anyio
async def test_create_node(mock_backend):
result = await _dispatch("create_node", {"type": "server", "label": "Proxmox"})
mock_backend.post.assert_called_once_with("/api/v1/nodes", {"type": "server", "label": "Proxmox"})
assert result == {"id": "1"}
@pytest.mark.anyio
async def test_update_node(mock_backend):
await _dispatch("update_node", {"id": "42", "label": "New name"})
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"label": "New name"})
@pytest.mark.anyio
async def test_update_node_parent_id(mock_backend):
await _dispatch("update_node", {"id": "42", "parent_id": "proxmox-1"})
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
@pytest.mark.anyio
async def test_delete_node(mock_backend):
await _dispatch("delete_node", {"id": "42"})
mock_backend.delete.assert_called_once_with("/api/v1/nodes/42")
@pytest.mark.anyio
async def test_create_edge(mock_backend):
await _dispatch("create_edge", {"source": "1", "target": "2", "type": "ethernet"})
mock_backend.post.assert_called_once_with("/api/v1/edges", {"source": "1", "target": "2", "type": "ethernet"})
@pytest.mark.anyio
async def test_delete_edge(mock_backend):
await _dispatch("delete_edge", {"id": "99"})
mock_backend.delete.assert_called_once_with("/api/v1/edges/99")
@pytest.mark.anyio
async def test_trigger_scan_no_ranges(mock_backend):
await _dispatch("trigger_scan", {})
mock_backend.post.assert_called_once_with("/api/v1/scan/trigger", {})
@pytest.mark.anyio
async def test_trigger_scan_with_ranges(mock_backend):
await _dispatch("trigger_scan", {"ranges": ["192.168.1.0/24"]})
mock_backend.post.assert_called_once_with("/api/v1/scan/trigger", {"ranges": ["192.168.1.0/24"]})
@pytest.mark.anyio
async def test_approve_device(mock_backend):
await _dispatch("approve_device", {"id": "5", "type": "server", "label": "MyServer"})
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/approve", {"type": "server", "label": "MyServer"})
@pytest.mark.anyio
async def test_hide_device(mock_backend):
await _dispatch("hide_device", {"id": "5"})
mock_backend.post.assert_called_once_with("/api/v1/scan/pending/5/hide", {})
@pytest.mark.anyio
async def test_get_canvas(mock_backend):
mock_backend.get = AsyncMock(return_value={
"nodes": [
{
"id": "n1",
"type": "router",
"position": {"x": 100, "y": 200},
"width": 160,
"height": 80,
"data": {"label": "Freebox", "ip": "192.168.1.1", "status": "online"},
}
],
"edges": [
{"id": "e1", "source": "n1", "target": "n2", "type": "ethernet", "animated": True, "style": {"stroke": "#fff"}},
],
"viewport": {"x": 0, "y": 0, "zoom": 1},
})
result = await _dispatch("get_canvas", {})
mock_backend.get.assert_called_once_with("/api/v1/canvas")
# Layout/style fields stripped, only semantic data kept
assert result["nodes"] == [{"id": "n1", "node_type": "router", "label": "Freebox", "ip": "192.168.1.1", "status": "online"}]
assert result["edges"] == [{"id": "e1", "source": "n1", "target": "n2", "type": "ethernet"}]
assert "viewport" not in result
@pytest.mark.anyio
async def test_list_nodes(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
result = await _dispatch("list_nodes", {})
mock_backend.get.assert_called_once_with("/api/v1/nodes")
assert result == [{"id": "1", "label": "Freebox"}]
@pytest.mark.anyio
async def test_list_pending_devices(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "p1", "ip": "192.168.1.50"}])
result = await _dispatch("list_pending_devices", {})
mock_backend.get.assert_called_once_with("/api/v1/scan/pending")
assert result == [{"id": "p1", "ip": "192.168.1.50"}]
@pytest.mark.anyio
async def test_unknown_tool():
with pytest.raises(ValueError, match="Unknown tool"):
await _dispatch("nonexistent", {})