Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2008f9467a | |||
| d9ac9462a8 | |||
| e14a9e87aa | |||
| e5d7260696 | |||
| 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 in seconds
|
||||||
STATUS_CHECKER_INTERVAL=60
|
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
|
||||||
.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.
|
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.
|
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
|
## Quick Start — Docker
|
||||||
@@ -72,6 +84,20 @@ The backend runs as a systemd service, the frontend is served via nginx on port
|
|||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
|
### Update
|
||||||
|
|
||||||
|
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/homelable/scripts/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly from GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
@@ -150,6 +176,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
|
## Development Mode
|
||||||
|
|
||||||
**Backend (Python 3.13):**
|
**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 fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import decode_token
|
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)
|
username = decode_token(credentials.credentials)
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|||||||
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
|
|||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
if not token or not decode_token(token):
|
# Accept first so we can send a close frame with a reason code
|
||||||
await websocket.close(code=1008) # Policy Violation
|
|
||||||
return
|
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||||
|
raw = await websocket.receive_text()
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
token = payload.get("token", "")
|
||||||
|
except (json.JSONDecodeError, AttributeError):
|
||||||
|
token = ""
|
||||||
|
if not token or not decode_token(token):
|
||||||
|
await websocket.close(code=1008) # Policy Violation
|
||||||
|
return
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
_connections.append(websocket)
|
_connections.append(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
|||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
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:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
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")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
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]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class Edge(Base):
|
|||||||
speed: Mapped[str | None] = mapped_column(String)
|
speed: Mapped[str | None] = mapped_column(String)
|
||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: 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)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class EdgeSave(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class EdgeBase(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class NodeUpdate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
pos_x: float | None = None
|
pos_x: float | None = None
|
||||||
pos_y: float | None = None
|
pos_y: float | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
container_mode: bool | None = None
|
container_mode: bool | None = None
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient):
|
|||||||
res = await client.get("/api/v1/health")
|
res = await client.get("/api/v1/health")
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json() == {"status": "ok"}
|
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
|
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):
|
async def test_create_node_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -22,24 +22,33 @@ def _make_token() -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_websocket_rejected_without_token():
|
def test_websocket_rejected_without_token():
|
||||||
"""Connection with no token must be closed before being accepted."""
|
"""Connection that sends no token field must be closed with 1008."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({})) # missing token field
|
||||||
|
ws.receive_text() # triggers WebSocketDisconnect from server close
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_rejected_with_invalid_token():
|
def test_websocket_rejected_with_invalid_token():
|
||||||
"""Connection with a garbage token must be closed."""
|
"""Connection that sends a garbage token must be closed."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({"token": "not-a-valid-jwt"}))
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejected_with_malformed_json():
|
||||||
|
"""Connection that sends non-JSON as auth must be closed."""
|
||||||
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
|
ws.send_text("not-json")
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_accepted_with_valid_token():
|
def test_websocket_accepted_with_valid_token():
|
||||||
"""Connection with a valid JWT must be accepted and kept open."""
|
"""Connection that sends a valid JWT as first message must be accepted."""
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
|
with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
# Connection is open — we can send a ping and it should not raise
|
ws.send_text(json.dumps({"token": token}))
|
||||||
|
# Connection is open — subsequent messages should not raise
|
||||||
ws.send_text("ping")
|
ws.send_text("ping")
|
||||||
# Server keeps the connection open (no disconnect expected)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- 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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,16 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy WebSocket (must be before /api/ to take priority)
|
||||||
|
location /api/v1/status/ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy API to backend
|
# Proxy API to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
@@ -11,7 +21,7 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy WebSocket
|
# Proxy legacy /ws/ path
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
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",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -5474,9 +5474,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.4",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
|
||||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -5826,9 +5826,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.5",
|
"version": "4.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -8770,9 +8770,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.22.0",
|
"version": "7.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+62
-16
@@ -2,6 +2,8 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
@@ -16,6 +18,8 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
|||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
@@ -28,7 +32,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
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 canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
@@ -36,6 +40,8 @@ export default function App() {
|
|||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
@@ -111,6 +117,7 @@ export default function App() {
|
|||||||
speed: e.data?.speed ?? null,
|
speed: e.data?.speed ?? null,
|
||||||
custom_color: e.data?.custom_color ?? null,
|
custom_color: e.data?.custom_color ?? null,
|
||||||
path_style: e.data?.path_style ?? 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;
|
// 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.
|
// 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),
|
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
|
||||||
@@ -200,20 +207,39 @@ export default function App() {
|
|||||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||||
}, [isAuthenticated, loadCanvas, setTheme])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
e.preventDefault()
|
// Ignore shortcuts when typing in an input/textarea
|
||||||
handleSaveRef.current()
|
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)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
const id = crypto.randomUUID()
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isProxmox = data.type === 'proxmox'
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
// Children position is relative to parent; place near top-left with padding
|
// Children position is relative to parent; place near top-left with padding
|
||||||
@@ -231,10 +257,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
}, [addNode, nodes])
|
}, [addNode, nodes, snapshotHistory])
|
||||||
|
|
||||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
const id = crypto.randomUUID()
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: 'groupRect',
|
type: 'groupRect',
|
||||||
@@ -258,7 +285,7 @@ export default function App() {
|
|||||||
zIndex: data.z_order - 10,
|
zIndex: data.z_order - 10,
|
||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
}, [addNode])
|
}, [addNode, snapshotHistory])
|
||||||
|
|
||||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
@@ -281,9 +308,10 @@ export default function App() {
|
|||||||
|
|
||||||
const handleDeleteGroupRect = useCallback(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
|
snapshotHistory()
|
||||||
deleteNode(editingGroupRectId)
|
deleteNode(editingGroupRectId)
|
||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
|
}, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
const handleEditNode = useCallback((id: string) => {
|
const handleEditNode = useCallback((id: string) => {
|
||||||
setEditNodeId(id)
|
setEditNodeId(id)
|
||||||
@@ -291,6 +319,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
||||||
if (!editNodeId) return
|
if (!editNodeId) return
|
||||||
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||||
@@ -320,7 +349,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEditNodeId(null)
|
setEditNodeId(null)
|
||||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect])
|
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
const handleAutoLayout = useCallback(() => {
|
||||||
const laid = applyDagreLayout(nodes, edges)
|
const laid = applyDagreLayout(nodes, edges)
|
||||||
@@ -328,6 +357,13 @@ export default function App() {
|
|||||||
toast.success('Canvas auto-arranged')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [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 handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -345,6 +381,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
||||||
if (!pendingConnection) return
|
if (!pendingConnection) return
|
||||||
|
snapshotHistory()
|
||||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
@@ -359,7 +396,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
}, [pendingConnection, onConnect, nodes, updateNode])
|
}, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
@@ -367,15 +404,17 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
updateEdge(editEdgeId, data)
|
updateEdge(editEdgeId, data)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, updateEdge])
|
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDelete = useCallback(() => {
|
const handleEdgeDelete = useCallback(() => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
deleteEdge(editEdgeId)
|
deleteEdge(editEdgeId)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, deleteEdge])
|
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||||
|
|
||||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||||
@@ -399,10 +438,14 @@ export default function App() {
|
|||||||
onAutoLayout={handleAutoLayout}
|
onAutoLayout={handleAutoLayout}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
onChangeStyle={() => setThemeModalOpen(true)}
|
onChangeStyle={() => setThemeModalOpen(true)}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
onExportMd={handleExportMd}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
|
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||||
</div>
|
</div>
|
||||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -496,6 +539,9 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
|
onNodeDragStart?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
@@ -55,6 +56,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
|
useStore,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
type Edge,
|
type Edge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
@@ -17,9 +18,12 @@ function getVlanColor(vlanId?: number): string {
|
|||||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
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 activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[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 pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
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)` } : {}),
|
...(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} />
|
<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 && (
|
{data?.label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
|
const [animated, setAnimated] = useState(initial?.animated ?? false)
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
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,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
|
animated: animated || undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,22 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
</div>
|
</div>
|
||||||
</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 flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
<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 [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
|
const [labelError, setLabelError] = useState(false)
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.label?.trim()) return
|
if (!form.label?.trim()) {
|
||||||
|
setLabelError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLabelError(false)
|
||||||
onSubmit(form)
|
onSubmit(form)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -167,11 +172,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.label ?? ''}
|
value={form.label ?? ''}
|
||||||
onChange={(e) => set('label', e.target.value)}
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||||
placeholder="My Server"
|
placeholder="My Server"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hostname */}
|
{/* 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 */}
|
{/* Details */}
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||||||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
<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.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{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 { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -8,24 +8,53 @@ interface ToolbarProps {
|
|||||||
onAutoLayout: () => void
|
onAutoLayout: () => void
|
||||||
onExport: () => void
|
onExport: () => void
|
||||||
onChangeStyle: () => void
|
onChangeStyle: () => void
|
||||||
|
onUndo: () => void
|
||||||
|
onRedo: () => void
|
||||||
|
onShortcuts: () => void
|
||||||
|
onExportMd: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||||
<Logo size={28} showText={true} />
|
<Logo size={28} showText={true} />
|
||||||
<div className="flex-1" />
|
<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}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</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
|
<Download size={14} /> Export
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative"
|
||||||
|
|||||||
@@ -23,12 +23,17 @@ export function useStatusPolling() {
|
|||||||
if (STANDALONE || !isAuthenticated || !token) return
|
if (STANDALONE || !isAuthenticated || !token) return
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
const host = window.location.hostname
|
const host = window.location.host // includes port when non-standard
|
||||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}`
|
const url = `${protocol}://${host}/api/v1/status/ws/status`
|
||||||
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
// Send token as first message (not in URL to avoid log/history exposure)
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ token }))
|
||||||
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
|
|||||||
@@ -115,3 +115,13 @@
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
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,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -234,4 +237,95 @@ describe('canvasStore', () => {
|
|||||||
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
||||||
expect(parentIdx).toBeLessThan(childIdx)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
|
||||||
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
@@ -18,6 +21,18 @@ interface CanvasState {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
scanEventTs: number
|
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
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
onConnect: (connection: Connection) => void
|
onConnect: (connection: Connection) => void
|
||||||
@@ -48,6 +63,67 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
hideIp: false,
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
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: generateUUID(),
|
||||||
|
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) =>
|
onNodesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: applyNodeChanges(changes, state.nodes),
|
nodes: applyNodeChanges(changes, state.nodes),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
speed?: string
|
speed?: string
|
||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
|
animated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
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,26 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import { generateUUID } from '../uuid'
|
||||||
|
|
||||||
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
describe('generateUUID', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid v4 UUID using crypto.randomUUID when available', () => {
|
||||||
|
const id = generateUUID()
|
||||||
|
expect(id).toMatch(UUID_REGEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a valid v4 UUID using crypto.getRandomValues fallback', () => {
|
||||||
|
vi.spyOn(crypto, 'randomUUID' as never).mockImplementation(undefined as never)
|
||||||
|
const id = generateUUID()
|
||||||
|
expect(id).toMatch(UUID_REGEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates unique IDs', () => {
|
||||||
|
const ids = new Set(Array.from({ length: 100 }, () => generateUUID()))
|
||||||
|
expect(ids.size).toBe(100)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,25 @@
|
|||||||
|
/**
|
||||||
|
* Generates a UUID v4.
|
||||||
|
* Falls back to a manual implementation when crypto.randomUUID is unavailable
|
||||||
|
* (HTTP non-secure contexts, older browsers).
|
||||||
|
*/
|
||||||
|
export function generateUUID(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
// Fallback: RFC 4122 v4 UUID using crypto.getRandomValues if available
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||||
|
return [...bytes]
|
||||||
|
.map((b, i) => ([4, 6, 8, 10].includes(i) ? '-' : '') + b.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
// Last resort: Math.random based (not cryptographically secure)
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0
|
||||||
|
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -28,5 +28,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/__tests__/**", "src/test/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", {})
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Homelable — update to latest version
|
||||||
|
# Run inside the LXC / any Linux host where lxc-install.sh was used:
|
||||||
|
# bash /opt/homelable/scripts/update.sh
|
||||||
|
# Or pull-and-run directly:
|
||||||
|
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR=/opt/homelable
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
||||||
|
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
|
||||||
|
|
||||||
|
# ── Pull latest code ──────────────────────────────────────────────────────────
|
||||||
|
info "Pulling latest code..."
|
||||||
|
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||||
|
git -C "$INSTALL_DIR" pull --quiet
|
||||||
|
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||||
|
|
||||||
|
if [[ "$BEFORE" == "$AFTER" ]]; then
|
||||||
|
info "Already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "Changes since last update:"
|
||||||
|
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Stop backend ─────────────────────────────────────────────────────────────
|
||||||
|
info "Stopping backend service..."
|
||||||
|
systemctl stop homelable-backend
|
||||||
|
|
||||||
|
# ── Backend deps ─────────────────────────────────────────────────────────────
|
||||||
|
info "Updating Python dependencies..."
|
||||||
|
cd "$INSTALL_DIR/backend"
|
||||||
|
.venv/bin/pip install --quiet -r requirements.txt
|
||||||
|
|
||||||
|
# ── Frontend build ────────────────────────────────────────────────────────────
|
||||||
|
info "Rebuilding frontend..."
|
||||||
|
cd "$INSTALL_DIR/frontend"
|
||||||
|
npm ci --silent
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# ── nginx config ─────────────────────────────────────────────────────────────
|
||||||
|
info "Updating nginx config..."
|
||||||
|
sed \
|
||||||
|
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
||||||
|
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
||||||
|
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
# ── Restart backend ───────────────────────────────────────────────────────────
|
||||||
|
info "Starting backend service..."
|
||||||
|
systemctl start homelable-backend
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e " ${GREEN}Homelable updated successfully!${NC}"
|
||||||
|
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user