Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df3b7a8cb0 | |||
| 426af29180 | |||
| f36bdfe878 | |||
| 300567c88d | |||
| e41dbe579c | |||
| e1d16b86e3 | |||
| 593335648f | |||
| e3f8c27a04 | |||
| ff69856d31 | |||
| 7935b671d3 | |||
| f8cadba17b | |||
| 5f7cb1bf11 | |||
| 3fb3bf016b | |||
| ba032a45af | |||
| 68b35a0c30 | |||
| 41cfccbd37 | |||
| 55a842cdad | |||
| 7074c5387b |
@@ -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
|
||||
|
||||
@@ -48,3 +48,4 @@ htmlcov/
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
Ideas.md
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
Generated
+11
-11
@@ -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": {
|
||||
|
||||
+59
-14
@@ -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'
|
||||
@@ -16,6 +17,8 @@ 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'
|
||||
@@ -28,7 +31,7 @@ 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()
|
||||
@@ -36,6 +39,8 @@ export default function App() {
|
||||
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)
|
||||
@@ -111,6 +116,7 @@ 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),
|
||||
@@ -200,19 +206,38 @@ export default function App() {
|
||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||
}, [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
|
||||
@@ -231,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,
|
||||
@@ -258,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
|
||||
@@ -281,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)
|
||||
@@ -291,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)
|
||||
@@ -320,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)
|
||||
@@ -328,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 }
|
||||
@@ -345,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') {
|
||||
@@ -359,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)
|
||||
@@ -367,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
|
||||
@@ -399,10 +437,14 @@ export default function App() {
|
||||
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>
|
||||
@@ -496,6 +538,9 @@ export default function App() {
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -20,9 +20,10 @@ 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,
|
||||
@@ -55,6 +56,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
snapToGrid
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EdgeLabelRenderer,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
useStore,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
@@ -17,9 +18,12 @@ function getVlanColor(vlanId?: number): string {
|
||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||
}
|
||||
|
||||
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
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'
|
||||
|
||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
||||
@@ -46,9 +50,42 @@ export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePo
|
||||
...(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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -43,13 +43,18 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.label?.trim()) return
|
||||
if (!form.label?.trim()) {
|
||||
setLabelError(true)
|
||||
return
|
||||
}
|
||||
setLabelError(false)
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
@@ -167,11 +172,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||
<Input
|
||||
value={form.label ?? ''}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||
placeholder="My Server"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
required
|
||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||
/>
|
||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||
</div>
|
||||
|
||||
{/* Hostname */}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const nodes = useCanvasStore((s) => s.nodes)
|
||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||
const { fitView } = useReactFlow()
|
||||
|
||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
const q = query.toLowerCase()
|
||||
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
||||
n.data.label?.toLowerCase().includes(q) ||
|
||||
n.data.ip?.toLowerCase().includes(q) ||
|
||||
n.data.hostname?.toLowerCase().includes(q)
|
||||
).slice(0, 8)
|
||||
|
||||
const handleSelect = useCallback((nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||
onClose()
|
||||
setQuery('')
|
||||
}, [fitView, setSelectedNode, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" onClick={onClose}>
|
||||
<div
|
||||
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||
<Search size={16} className="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search nodes by label, IP, hostname…"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
||||
}}
|
||||
/>
|
||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ul className="py-1 max-h-64 overflow-y-auto">
|
||||
{results.map((node) => (
|
||||
<li
|
||||
key={node.id}
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelect(node.id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||
{node.data.ip && (
|
||||
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{q.length > 0 && results.length === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
||||
)}
|
||||
|
||||
{q.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const SHORTCUTS = [
|
||||
{
|
||||
group: 'Canvas',
|
||||
items: [
|
||||
{ keys: ['Ctrl', 'S'], description: 'Save canvas' },
|
||||
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
|
||||
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
|
||||
{ keys: ['Ctrl', 'K'], description: 'Search nodes' },
|
||||
{ keys: ['?'], description: 'Show this help' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Nodes',
|
||||
items: [
|
||||
{ keys: ['Ctrl', 'C'], description: 'Copy selected nodes' },
|
||||
{ keys: ['Ctrl', 'V'], description: 'Paste nodes' },
|
||||
{ keys: ['Del'], description: 'Delete selected node/edge' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Navigation',
|
||||
items: [
|
||||
{ keys: ['Scroll'], description: 'Zoom in / out' },
|
||||
{ keys: ['Space', '+', 'Drag'], description: 'Pan canvas' },
|
||||
{ keys: ['Ctrl', 'Shift', 'F'], description: 'Fit view' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface ShortcutsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ShortcutsModal({ open, onClose }: ShortcutsModalProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||
<div
|
||||
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{SHORTCUTS.map((group) => (
|
||||
<div key={group.group}>
|
||||
<p className="text-xs text-[#00d4ff] font-semibold mb-2 uppercase tracking-wide">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.description} className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">{item.description}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{item.keys.map((k, i) => (
|
||||
k === '+' ? (
|
||||
<span key={i} className="text-xs text-muted-foreground">+</span>
|
||||
) : (
|
||||
<kbd key={k} className="text-[11px] text-foreground border border-border rounded px-1.5 py-0.5 font-mono bg-[#0d1117]">
|
||||
{k}
|
||||
</kbd>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NodeModal } from '../NodeModal'
|
||||
|
||||
describe('NodeModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(
|
||||
<NodeModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||
)
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders form fields when open', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('Add Node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not call onSubmit when label is empty and shows error', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Label is required')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onSubmit with form data when label is filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'My NAS' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('My NAS')
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears label error when user starts typing', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(screen.getByText('Label is required')).toBeDefined()
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'x' } })
|
||||
expect(screen.queryByText('Label is required')).toBeNull()
|
||||
})
|
||||
|
||||
it('pre-fills form from initial prop', () => {
|
||||
render(
|
||||
<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'Pre-filled', ip: '10.0.0.1' }} />
|
||||
)
|
||||
const input = screen.getByPlaceholderText('My Server') as HTMLInputElement
|
||||
expect(input.value).toBe('Pre-filled')
|
||||
})
|
||||
|
||||
it('shows Save button text when title is Edit Node', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Node" />)
|
||||
expect(screen.getByText('Save')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClose when Cancel is clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NodeModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ShortcutsModal } from '../ShortcutsModal'
|
||||
|
||||
describe('ShortcutsModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ShortcutsModal open={false} onClose={vi.fn()} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders shortcut groups when open', () => {
|
||||
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText('Keyboard Shortcuts')).toBeDefined()
|
||||
expect(screen.getByText('Canvas')).toBeDefined()
|
||||
expect(screen.getByText('Nodes')).toBeDefined()
|
||||
expect(screen.getByText('Navigation')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows key shortcuts in kbd elements', () => {
|
||||
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||
fireEvent.click(container.firstChild as HTMLElement)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose when X button clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Save, LayoutDashboard, Download, Palette } 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'
|
||||
@@ -8,24 +8,53 @@ interface ToolbarProps {
|
||||
onAutoLayout: () => void
|
||||
onExport: () => void
|
||||
onChangeStyle: () => void
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onShortcuts: () => void
|
||||
onExportMd: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: 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={onChangeStyle}>
|
||||
<Palette size={14} /> Style
|
||||
</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={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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -48,6 +62,67 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
@@ -0,0 +1,5 @@
|
||||
mcp[cli]>=1.0
|
||||
httpx>=0.27
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
pydantic-settings>=2.0
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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", {})
|
||||
Reference in New Issue
Block a user