Compare commits

..

22 Commits

Author SHA1 Message Date
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
66 changed files with 2671 additions and 143 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>
@@ -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,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", {})