Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9787fdcbb | |||
| 06ec18a137 | |||
| adb4474687 | |||
| 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=60
|
||||
|
||||
# MCP server — used by the mcp service (port 8001)
|
||||
# MCP_API_KEY: authenticates AI clients (Claude Code, etc.) → MCP server
|
||||
# MCP_SERVICE_KEY: authenticates MCP server → backend (never exposed externally)
|
||||
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
MCP_API_KEY=mcp_sk_changeme
|
||||
MCP_SERVICE_KEY=svc_changeme
|
||||
|
||||
@@ -48,3 +48,4 @@ htmlcov/
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
Ideas.md
|
||||
|
||||
@@ -4,9 +4,21 @@ Homelable is a self-hosted infrastructure visualization solution. It provides a
|
||||
|
||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||
|
||||
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||
|
||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start — Docker
|
||||
@@ -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)
|
||||
> ```
|
||||
|
||||
### 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
|
||||
@@ -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
|
||||
|
||||
**Backend (Python 3.13):**
|
||||
|
||||
+21
-3
@@ -1,12 +1,30 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
import hmac
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
|
||||
bearer = HTTPBearer()
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
||||
def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
x_mcp_service_key: str | None = Header(default=None),
|
||||
) -> str:
|
||||
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||
if x_mcp_service_key is not None:
|
||||
if not settings.mcp_service_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||
return "__mcp_service__"
|
||||
|
||||
# 2. Standard JWT bearer token
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
|
||||
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
|
||||
|
||||
|
||||
@router.websocket("/ws/status")
|
||||
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
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)
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
||||
# Status checker
|
||||
status_checker_interval: int = 60
|
||||
|
||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||
# Used by the MCP server to authenticate against the backend without a user password.
|
||||
# Leave empty to disable MCP service key auth.
|
||||
mcp_service_key: str = ""
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
@@ -40,6 +40,18 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||
with suppress(Exception):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
@@ -37,6 +37,11 @@ class Node(Base):
|
||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
@@ -58,6 +63,7 @@ class Edge(Base):
|
||||
speed: Mapped[str | None] = mapped_column(String)
|
||||
custom_color: Mapped[str | None] = mapped_column(String)
|
||||
path_style: Mapped[str | None] = mapped_column(String)
|
||||
animated: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
source_handle: Mapped[str | None] = mapped_column(String)
|
||||
target_handle: Mapped[str | None] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
@@ -23,6 +23,11 @@ class NodeSave(BaseModel):
|
||||
container_mode: bool = False
|
||||
custom_colors: dict[str, Any] | None = None
|
||||
custom_icon: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
@@ -37,6 +42,7 @@ class EdgeSave(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
animated: bool = False
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class EdgeBase(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
animated: bool = False
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
|
||||
@@ -27,6 +28,7 @@ class EdgeUpdate(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
animated: bool | None = None
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ class NodeBase(BaseModel):
|
||||
container_mode: bool = False
|
||||
custom_colors: dict[str, Any] | None = None
|
||||
custom_icon: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
@@ -42,9 +47,15 @@ class NodeUpdate(BaseModel):
|
||||
notes: str | None = None
|
||||
pos_x: float | None = None
|
||||
pos_y: float | None = None
|
||||
parent_id: str | None = None
|
||||
container_mode: bool | None = None
|
||||
custom_colors: dict[str, Any] | None = None
|
||||
custom_icon: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
|
||||
|
||||
class NodeResponse(NodeBase):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient):
|
||||
res = await client.get("/api/v1/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
|
||||
|
||||
# --- MCP service key auth ---
|
||||
|
||||
@pytest.fixture
|
||||
def with_service_key():
|
||||
from app.core.config import settings
|
||||
settings.mcp_service_key = "test-service-key"
|
||||
yield "test-service-key"
|
||||
settings.mcp_service_key = ""
|
||||
|
||||
|
||||
async def test_service_key_grants_access(client: AsyncClient, with_service_key):
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": with_service_key})
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
async def test_service_key_wrong_value(client: AsyncClient, with_service_key):
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "wrong-key"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
||||
from app.core.config import settings
|
||||
settings.mcp_service_key = ""
|
||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -138,3 +138,56 @@ async def test_save_canvas_custom_icon_cleared_when_null(client: AsyncClient, he
|
||||
async def test_save_canvas_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_save_canvas_persists_hardware_fields(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(cpu_count=8, cpu_model="Intel i7-12700K", ram_gb=32.0, disk_gb=500.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["cpu_count"] == 8
|
||||
assert node["cpu_model"] == "Intel i7-12700K"
|
||||
assert node["ram_gb"] == 32.0
|
||||
assert node["disk_gb"] == 500.0
|
||||
|
||||
|
||||
async def test_save_canvas_hardware_fields_nullable(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(cpu_count=4, ram_gb=16.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["cpu_count"] == 4
|
||||
assert node["ram_gb"] == 16.0
|
||||
assert node["cpu_model"] is None
|
||||
assert node["disk_gb"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_persists_show_hardware(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(show_hardware=True, cpu_count=4, ram_gb=16.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_hardware"] is True
|
||||
|
||||
|
||||
async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_hardware"] is False
|
||||
|
||||
|
||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_cleared = {**n1, "cpu_count": None, "ram_gb": None}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["cpu_count"] is None
|
||||
assert node["ram_gb"] is None
|
||||
|
||||
@@ -102,6 +102,16 @@ async def test_update_node_container_mode(client: AsyncClient, headers: dict):
|
||||
assert res.json()["container_mode"] is True
|
||||
|
||||
|
||||
async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||
parent = await client.post("/api/v1/nodes", json={"type": "proxmox", "label": "PVE", "status": "unknown"}, headers=headers)
|
||||
parent_id = parent.json()["id"]
|
||||
child = await client.post("/api/v1/nodes", json={"type": "lxc", "label": "Child", "status": "unknown"}, headers=headers)
|
||||
child_id = child.json()["id"]
|
||||
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": parent_id}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["parent_id"] == parent_id
|
||||
|
||||
|
||||
async def test_create_node_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -22,24 +22,33 @@ def _make_token() -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_websocket_rejected_without_token():
|
||||
"""Connection with no token must be closed before being accepted."""
|
||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
|
||||
pass
|
||||
"""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") as ws:
|
||||
ws.send_text(json.dumps({})) # missing token field
|
||||
ws.receive_text() # triggers WebSocketDisconnect from server close
|
||||
|
||||
|
||||
def test_websocket_rejected_with_invalid_token():
|
||||
"""Connection with 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"):
|
||||
pass
|
||||
"""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") as ws:
|
||||
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():
|
||||
"""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()
|
||||
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
|
||||
# Connection is open — we can send a ping and it should not raise
|
||||
with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||
ws.send_text(json.dumps({"token": token}))
|
||||
# Connection is open — subsequent messages should not raise
|
||||
ws.send_text("ping")
|
||||
# Server keeps the connection open (no disconnect expected)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -18,6 +18,22 @@ services:
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
|
||||
mcp:
|
||||
build:
|
||||
context: ./mcp
|
||||
dockerfile: Dockerfile.mcp
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BACKEND_URL: "http://backend:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
|
||||
+11
-1
@@ -4,6 +4,16 @@ server {
|
||||
root /usr/share/nginx/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
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
@@ -11,7 +21,7 @@ server {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Proxy WebSocket
|
||||
# Proxy legacy /ws/ path
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
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",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -5474,9 +5474,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.4",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -5826,9 +5826,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
||||
"version": "4.12.8",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -8770,9 +8770,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.24.3",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+70
-16
@@ -2,6 +2,8 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||
import { exportToPng } from '@/utils/export'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
@@ -16,6 +18,8 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -28,7 +32,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore()
|
||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
@@ -36,6 +40,8 @@ export default function App() {
|
||||
useStatusPolling()
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
@@ -97,6 +103,11 @@ export default function App() {
|
||||
container_mode: n.data.container_mode ?? false,
|
||||
custom_colors: n.data.custom_colors ?? null,
|
||||
custom_icon: n.data.custom_icon ?? null,
|
||||
cpu_count: n.data.cpu_count ?? null,
|
||||
cpu_model: n.data.cpu_model ?? null,
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
@@ -111,6 +122,7 @@ export default function App() {
|
||||
speed: e.data?.speed ?? null,
|
||||
custom_color: e.data?.custom_color ?? null,
|
||||
path_style: e.data?.path_style ?? null,
|
||||
animated: e.data?.animated ?? false,
|
||||
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
||||
// map them back to their canonical source handle ID so reload works correctly.
|
||||
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
|
||||
@@ -200,20 +212,39 @@ export default function App() {
|
||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||
}, [isAuthenticated, loadCanvas, setTheme])
|
||||
|
||||
// Ctrl+S
|
||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||
const undoRef = useRef(undo)
|
||||
const redoRef = useRef(redo)
|
||||
const copyRef = useRef(copySelectedNodes)
|
||||
const pasteRef = useRef(pasteNodes)
|
||||
useEffect(() => { undoRef.current = undo }, [undo])
|
||||
useEffect(() => { redoRef.current = redo }, [redo])
|
||||
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
||||
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSaveRef.current()
|
||||
}
|
||||
const ctrl = e.ctrlKey || e.metaKey
|
||||
// Ignore shortcuts when typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable
|
||||
|
||||
if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return }
|
||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
||||
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
|
||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||
const id = crypto.randomUUID()
|
||||
snapshotHistory()
|
||||
const id = generateUUID()
|
||||
const isProxmox = data.type === 'proxmox'
|
||||
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
|
||||
@@ -231,10 +262,11 @@ export default function App() {
|
||||
}
|
||||
addNode(newNode)
|
||||
toast.success(`Added "${data.label}"`)
|
||||
}, [addNode, nodes])
|
||||
}, [addNode, nodes, snapshotHistory])
|
||||
|
||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
const id = crypto.randomUUID()
|
||||
snapshotHistory()
|
||||
const id = generateUUID()
|
||||
const newNode: Node<NodeData> = {
|
||||
id,
|
||||
type: 'groupRect',
|
||||
@@ -246,6 +278,7 @@ export default function App() {
|
||||
services: [],
|
||||
custom_colors: {
|
||||
border: data.border_color,
|
||||
border_style: data.border_style,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_position: data.text_position,
|
||||
@@ -258,7 +291,7 @@ export default function App() {
|
||||
zIndex: data.z_order - 10,
|
||||
}
|
||||
addNode(newNode)
|
||||
}, [addNode])
|
||||
}, [addNode, snapshotHistory])
|
||||
|
||||
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||
if (!editingGroupRectId) return
|
||||
@@ -268,6 +301,7 @@ export default function App() {
|
||||
custom_colors: {
|
||||
...existing?.data.custom_colors,
|
||||
border: data.border_color,
|
||||
border_style: data.border_style,
|
||||
background: data.background_color,
|
||||
text_color: data.text_color,
|
||||
text_position: data.text_position,
|
||||
@@ -281,9 +315,10 @@ export default function App() {
|
||||
|
||||
const handleDeleteGroupRect = useCallback(() => {
|
||||
if (!editingGroupRectId) return
|
||||
snapshotHistory()
|
||||
deleteNode(editingGroupRectId)
|
||||
setEditingGroupRectId(null)
|
||||
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
|
||||
}, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory])
|
||||
|
||||
const handleEditNode = useCallback((id: string) => {
|
||||
setEditNodeId(id)
|
||||
@@ -291,6 +326,7 @@ export default function App() {
|
||||
|
||||
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
||||
if (!editNodeId) return
|
||||
snapshotHistory()
|
||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||
updateNode(editNodeId, data)
|
||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
@@ -320,7 +356,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
setEditNodeId(null)
|
||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect])
|
||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
const laid = applyDagreLayout(nodes, edges)
|
||||
@@ -328,6 +364,13 @@ export default function App() {
|
||||
toast.success('Canvas auto-arranged')
|
||||
}, [nodes, edges, loadCanvas])
|
||||
|
||||
const handleExportMd = useCallback(async () => {
|
||||
const md = generateMarkdownTable(nodes)
|
||||
if (!md) { toast.error('No nodes to export'); return }
|
||||
await navigator.clipboard.writeText(md)
|
||||
toast.success('Markdown table copied to clipboard')
|
||||
}, [nodes])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||
if (!el) { toast.error('Canvas not ready'); return }
|
||||
@@ -345,6 +388,7 @@ export default function App() {
|
||||
|
||||
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
||||
if (!pendingConnection) return
|
||||
snapshotHistory()
|
||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
||||
if (edgeData.type === 'virtual') {
|
||||
@@ -359,7 +403,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
setPendingConnection(null)
|
||||
}, [pendingConnection, onConnect, nodes, updateNode])
|
||||
}, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory])
|
||||
|
||||
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
||||
setEditEdgeId(edge.id)
|
||||
@@ -367,15 +411,17 @@ export default function App() {
|
||||
|
||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
updateEdge(editEdgeId, data)
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, updateEdge])
|
||||
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||
|
||||
const handleEdgeDelete = useCallback(() => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
deleteEdge(editEdgeId)
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, deleteEdge])
|
||||
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||
|
||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||
@@ -399,10 +445,14 @@ export default function App() {
|
||||
onAutoLayout={handleAutoLayout}
|
||||
onExport={handleExport}
|
||||
onChangeStyle={() => setThemeModalOpen(true)}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onShortcuts={() => setShortcutsOpen(true)}
|
||||
onExportMd={handleExportMd}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
|
||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||
</div>
|
||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||
</div>
|
||||
@@ -482,6 +532,7 @@ export default function App() {
|
||||
text_color: rc.text_color ?? '#e6edf3',
|
||||
text_position: rc.text_position ?? 'top-left',
|
||||
border_color: rc.border ?? '#00d4ff',
|
||||
border_style: rc.border_style ?? 'solid',
|
||||
background_color: rc.background ?? '#00d4ff0d',
|
||||
z_order: rc.z_order ?? 1,
|
||||
}
|
||||
@@ -496,6 +547,9 @@ export default function App() {
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -20,9 +20,10 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
interface CanvasContainerProps {
|
||||
onConnect?: (connection: Connection) => void
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
@@ -55,6 +56,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
snapToGrid
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
EdgeLabelRenderer,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
useStore,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
@@ -17,9 +18,12 @@ function getVlanColor(vlanId?: number): string {
|
||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||
}
|
||||
|
||||
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||
|
||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||
const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
|
||||
@@ -46,9 +50,42 @@ export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePo
|
||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||
}
|
||||
|
||||
// Animated dot: slightly brighter + thicker than the base edge, travels source→target
|
||||
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
{data?.animated && (
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke={dotColor}
|
||||
strokeWidth={dotWidth}
|
||||
strokeDasharray="20 10000"
|
||||
strokeLinecap="round"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{isBidirectional ? (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
values="-10000;0;-10000"
|
||||
keyTimes="0;0.5;1"
|
||||
dur="20s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
) : (
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="-10000"
|
||||
to="0"
|
||||
dur="10s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</path>
|
||||
)}
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement } from 'react'
|
||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { type LucideIcon } from 'lucide-react'
|
||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||
@@ -13,6 +13,11 @@ interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
function formatStorage(gb: number): string {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
@@ -22,10 +27,11 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
const statusColor = theme.colors.statusColors[data.status]
|
||||
const isOnline = data.status === 'online'
|
||||
const showHardware = data.show_hardware && (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-row items-center gap-2.5 px-2.5 py-2 rounded-lg border transition-all duration-200"
|
||||
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||
style={{
|
||||
background: colors.background,
|
||||
borderColor: colors.border,
|
||||
@@ -47,36 +53,77 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
style={{
|
||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
{createElement(resolvedIcon, { size: 15 })}
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||
style={{
|
||||
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
|
||||
background: theme.colors.nodeIconBackground,
|
||||
}}
|
||||
>
|
||||
{createElement(resolvedIcon, { size: 15 })}
|
||||
</div>
|
||||
|
||||
{/* Label + IP */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
||||
style={{ color: theme.colors.nodeLabelColor }}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
{data.ip && (
|
||||
<div
|
||||
className="font-mono text-[10px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
title={data.ip}
|
||||
>
|
||||
{hideIp ? maskIp(data.ip) : data.ip}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
||||
style={{ color: theme.colors.nodeLabelColor }}
|
||||
title={data.label}
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
{data.ip && (
|
||||
<div
|
||||
className="font-mono text-[10px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
title={data.ip}
|
||||
>
|
||||
{hideIp ? maskIp(data.ip) : data.ip}
|
||||
{/* Hardware section */}
|
||||
{showHardware && (
|
||||
<>
|
||||
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5">
|
||||
{/* Line 1: CPU */}
|
||||
{(data.cpu_model || data.cpu_count != null) && (
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
{data.cpu_model && (
|
||||
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
||||
)}
|
||||
{data.cpu_count != null && (
|
||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Line 2: RAM + Disk */}
|
||||
{(data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{data.ram_gb != null && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MemoryStick size={9} className="shrink-0" />
|
||||
{formatStorage(data.ram_gb)}
|
||||
</span>
|
||||
)}
|
||||
{data.disk_gb != null && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<HardDrive size={9} className="shrink-0" />
|
||||
{formatStorage(data.disk_gb)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
|
||||
@@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
const borderStyle = rc.border_style ?? 'solid'
|
||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||
const textColor = rc.text_color ?? '#e6edf3'
|
||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||
@@ -61,7 +62,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
justifyContent: posStyle.justifyContent,
|
||||
padding: 12,
|
||||
background: backgroundColor,
|
||||
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
|
||||
border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||
borderRadius: 10,
|
||||
fontFamily,
|
||||
color: textColor,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -22,4 +22,5 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
|
||||
@@ -17,6 +17,7 @@ export const nodeTypes = {
|
||||
printer: PrinterNode,
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
docker: DockerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||
const [animated, setAnimated] = useState(initial?.animated ?? false)
|
||||
|
||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||
|
||||
@@ -36,6 +37,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||
custom_color: customColor,
|
||||
path_style: pathStyle,
|
||||
animated: animated || undefined,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
@@ -113,6 +115,22 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Flow Animation</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAnimated((a) => !a)}
|
||||
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
|
||||
style={{ background: animated ? '#00d4ff' : '#30363d' }}
|
||||
aria-pressed={animated}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||
|
||||
@@ -6,22 +6,34 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { TextPosition } from '@/types'
|
||||
|
||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
|
||||
export interface GroupRectFormData {
|
||||
label: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_position: TextPosition
|
||||
border_color: string
|
||||
border_style: BorderStyle
|
||||
background_color: string
|
||||
z_order: number
|
||||
}
|
||||
|
||||
const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] = [
|
||||
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||
{ value: 'double', label: 'Double', preview: '═══' },
|
||||
{ value: 'none', label: 'None', preview: ' ' },
|
||||
]
|
||||
|
||||
const DEFAULT_FORM: GroupRectFormData = {
|
||||
label: '',
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_position: 'top-left',
|
||||
border_color: '#00d4ff',
|
||||
border_style: 'solid',
|
||||
background_color: '#00d4ff0d',
|
||||
z_order: 1,
|
||||
}
|
||||
@@ -157,6 +169,33 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Border style */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||
const isSelected = form.border_style === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
title={label}
|
||||
onClick={() => set('border_style', value)}
|
||||
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
||||
style={{
|
||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||
<span className="text-[9px]">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Z-order */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||
|
||||
@@ -4,12 +4,17 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
||||
|
||||
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||
|
||||
@@ -43,13 +48,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
const hasHardwareData = !!(initial?.cpu_count || initial?.cpu_model || initial?.ram_gb || initial?.disk_gb)
|
||||
const [hardwareOpen, setHardwareOpen] = useState(hasHardwareData)
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.label?.trim()) return
|
||||
if (!form.label?.trim()) {
|
||||
setLabelError(true)
|
||||
return
|
||||
}
|
||||
setLabelError(false)
|
||||
onSubmit(form)
|
||||
onClose()
|
||||
}
|
||||
@@ -71,10 +83,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{NODE_TYPES.map(([value, label]) => (
|
||||
<SelectItem key={value} value={value} className="text-sm">
|
||||
{label}
|
||||
</SelectItem>
|
||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||
<>
|
||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||
<SelectGroup key={group.label}>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||
{group.label}
|
||||
</SelectLabel>
|
||||
{group.types.map((type) => (
|
||||
<SelectItem key={type} value={type} className="text-sm pl-4">
|
||||
{NODE_TYPE_LABELS[type]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -167,11 +189,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||
<Input
|
||||
value={form.label ?? ''}
|
||||
onChange={(e) => set('label', e.target.value)}
|
||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||
placeholder="My Server"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
required
|
||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||
/>
|
||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||
</div>
|
||||
|
||||
{/* Hostname */}
|
||||
@@ -310,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hardware specs (hidden for groupRect) */}
|
||||
{form.type !== 'groupRect' && (
|
||||
<div className="flex flex-col gap-2 col-span-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHardwareOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="font-medium">Hardware</span>
|
||||
<ChevronDown size={12} style={{ transform: hardwareOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{hardwareOpen && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/60">Show on node</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.show_hardware}
|
||||
onClick={() => set('show_hardware', !form.show_hardware)}
|
||||
className="relative inline-flex h-4 w-7 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||
style={{ background: form.show_hardware ? '#00d4ff' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.show_hardware ? 'translateX(12px)' : 'translateX(0)' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hardwareOpen && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">CPU Model</Label>
|
||||
<Input
|
||||
value={form.cpu_model ?? ''}
|
||||
onChange={(e) => set('cpu_model', e.target.value || undefined)}
|
||||
placeholder="e.g. Intel Xeon E5-2680"
|
||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">CPU Cores</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.cpu_count ?? ''}
|
||||
onChange={(e) => set('cpu_count', e.target.value ? parseInt(e.target.value, 10) : undefined)}
|
||||
placeholder="e.g. 8"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">RAM (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={form.ram_gb ?? ''}
|
||||
onChange={(e) => set('ram_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="e.g. 32"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Disk (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.disk_gb ?? ''}
|
||||
onChange={(e) => set('disk_gb', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="e.g. 500"
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -80,4 +80,56 @@ describe('GroupRectModal', () => {
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.text_position).toBe('bottom-right')
|
||||
})
|
||||
|
||||
it('renders Border Style section', () => {
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Border Style')).toBeDefined()
|
||||
expect(screen.getByTitle('Solid')).toBeDefined()
|
||||
expect(screen.getByTitle('Dashed')).toBeDefined()
|
||||
expect(screen.getByTitle('Dotted')).toBeDefined()
|
||||
expect(screen.getByTitle('Double')).toBeDefined()
|
||||
expect(screen.getByTitle('None')).toBeDefined()
|
||||
})
|
||||
|
||||
it('defaults border_style to solid', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_style).toBe('solid')
|
||||
})
|
||||
|
||||
it('selects border style on click', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByTitle('Dashed'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_style).toBe('dashed')
|
||||
})
|
||||
|
||||
it('pre-fills border_style from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<GroupRectModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ border_style: 'dotted' }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_style).toBe('dotted')
|
||||
})
|
||||
|
||||
it('toggles border style — clicking selected style deselects back to solid', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByTitle('Dotted'))
|
||||
fireEvent.click(screen.getByTitle('Solid'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||
expect(submitted.border_style).toBe('solid')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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()
|
||||
})
|
||||
|
||||
describe('Hardware section', () => {
|
||||
it('renders Hardware toggle button', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hardware fields are hidden by default', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands hardware fields on toggle click', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 8')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 32')).toBeDefined()
|
||||
expect(screen.getByPlaceholderText('e.g. 500')).toBeDefined()
|
||||
})
|
||||
|
||||
it('submits hardware fields when filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Homelab' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680'), { target: { value: 'Intel i7-12700K' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 8'), { target: { value: '12' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 32'), { target: { value: '64' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 500'), { target: { value: '2000' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
const submitted = onSubmit.mock.calls[0][0]
|
||||
expect(submitted.cpu_model).toBe('Intel i7-12700K')
|
||||
expect(submitted.cpu_count).toBe(12)
|
||||
expect(submitted.ram_gb).toBe(64)
|
||||
expect(submitted.disk_gb).toBe(2000)
|
||||
})
|
||||
|
||||
it('auto-expands when initial has hardware data', () => {
|
||||
render(
|
||||
<NodeModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ label: 'Server', cpu_count: 8, ram_gb: 32 }}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByPlaceholderText('e.g. Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides hardware section for groupRect type', () => {
|
||||
render(
|
||||
<NodeModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
initial={{ type: 'groupRect' }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
})
|
||||
|
||||
it('show on node toggle is hidden when section is collapsed', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.queryByText('Show on node')).toBeNull()
|
||||
})
|
||||
|
||||
it('show on node toggle appears when section is expanded', () => {
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
expect(screen.getByText('Show on node')).toBeDefined()
|
||||
})
|
||||
|
||||
it('show_hardware defaults to false', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBeFalsy()
|
||||
})
|
||||
|
||||
it('toggling show on node sets show_hardware to true', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<NodeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('My Server'), { target: { value: 'Node' } })
|
||||
fireEvent.click(screen.getByText('Hardware'))
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
|
||||
it('pre-fills show_hardware from initial prop', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(
|
||||
<NodeModal
|
||||
open
|
||||
onClose={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
initial={{ label: 'Node', show_hardware: true, cpu_count: 8 }}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByText('Add'))
|
||||
expect(onSubmit.mock.calls[0][0].show_hardware).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ShortcutsModal } from '../ShortcutsModal'
|
||||
|
||||
describe('ShortcutsModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
const { container } = render(<ShortcutsModal open={false} onClose={vi.fn()} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders shortcut groups when open', () => {
|
||||
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getByText('Keyboard Shortcuts')).toBeDefined()
|
||||
expect(screen.getByText('Canvas')).toBeDefined()
|
||||
expect(screen.getByText('Nodes')).toBeDefined()
|
||||
expect(screen.getByText('Navigation')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows key shortcuts in kbd elements', () => {
|
||||
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('calls onClose when backdrop clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||
fireEvent.click(container.firstChild as HTMLElement)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClose when X button clicked', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -76,7 +76,21 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{/* Details */}
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
||||
{data.hostname && <DetailRow label="Hostname" value={data.hostname} mono />}
|
||||
{data.hostname && (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
||||
<a
|
||||
href={`http://${data.hostname}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
|
||||
title={data.hostname}
|
||||
>
|
||||
{data.hostname}
|
||||
<ExternalLink size={10} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
@@ -86,6 +100,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hardware */}
|
||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm border-t border-border">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Hardware</span>
|
||||
{data.cpu_model && <DetailRow label="CPU" value={data.cpu_model} />}
|
||||
{data.cpu_count != null && <DetailRow label="Cores" value={String(data.cpu_count)} mono />}
|
||||
{data.ram_gb != null && <DetailRow label="RAM" value={formatStorage(data.ram_gb)} mono />}
|
||||
{data.disk_gb != null && <DetailRow label="Disk" value={formatStorage(data.disk_gb)} mono />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -188,6 +213,11 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatStorage(gb: number): string {
|
||||
if (gb >= 1024) return `${(gb / 1024).toFixed(1).replace(/\.0$/, '')} TB`
|
||||
return `${gb} GB`
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Save, LayoutDashboard, Download, Palette } from 'lucide-react'
|
||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -8,24 +8,53 @@ interface ToolbarProps {
|
||||
onAutoLayout: () => void
|
||||
onExport: () => void
|
||||
onChangeStyle: () => void
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
onShortcuts: () => void
|
||||
onExportMd: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) {
|
||||
const { hasUnsavedChanges } = useCanvasStore()
|
||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
|
||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||
<Logo size={28} showText={true} />
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
onClick={onUndo}
|
||||
disabled={past.length === 0}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
onClick={onRedo}
|
||||
disabled={future.length === 0}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 size={14} />
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
||||
<LayoutDashboard size={14} /> Auto Layout
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||
<Palette size={14} /> Style
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
|
||||
<Download size={14} /> Export
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||
<Table2 size={14} /> MD
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||
<HelpCircle size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5 relative"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { DetailPanel } from '../DetailPanel'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
id: 'n1',
|
||||
type: data.type ?? 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Node',
|
||||
type: 'server',
|
||||
status: 'online',
|
||||
services: [],
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [makeNode(nodeData)],
|
||||
selectedNodeId: 'n1',
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
}
|
||||
|
||||
describe('DetailPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
selectedNodeId: null,
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
})
|
||||
|
||||
it('renders nothing when no node is selected', () => {
|
||||
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders node label and status', () => {
|
||||
setupStore({ label: 'My Server', status: 'online' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('My Server')).toBeDefined()
|
||||
expect(screen.getByText('online')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders nothing for groupRect nodes', () => {
|
||||
setupStore({ type: 'groupRect', label: 'Zone' })
|
||||
const { container } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
describe('Hardware section', () => {
|
||||
it('does not render hardware section when no hardware data', () => {
|
||||
setupStore({ label: 'Server' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('Hardware')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders hardware section when cpu_count is set', () => {
|
||||
setupStore({ cpu_count: 8 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('8')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders cpu_model', () => {
|
||||
setupStore({ cpu_model: 'Intel Xeon E5-2680' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb in GB', () => {
|
||||
setupStore({ ram_gb: 32 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('32 GB')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats ram_gb >= 1024 as TB', () => {
|
||||
setupStore({ ram_gb: 2048 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('2 TB')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats disk_gb in GB', () => {
|
||||
setupStore({ disk_gb: 500 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('500 GB')).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats disk_gb >= 1024 as TB', () => {
|
||||
setupStore({ disk_gb: 1536 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('1.5 TB')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders all hardware fields together', () => {
|
||||
setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('Hardware')).toBeDefined()
|
||||
expect(screen.getByText('AMD EPYC')).toBeDefined()
|
||||
expect(screen.getByText('16')).toBeDefined()
|
||||
expect(screen.getByText('128 GB')).toBeDefined()
|
||||
expect(screen.getByText('4 TB')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,12 +23,17 @@ export function useStatusPolling() {
|
||||
if (STANDALONE || !isAuthenticated || !token) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.hostname
|
||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}`
|
||||
const host = window.location.host // includes port when non-standard
|
||||
const url = `${protocol}://${host}/api/v1/status/ws/status`
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
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) => {
|
||||
try {
|
||||
const msg: StatusMessage = JSON.parse(event.data)
|
||||
|
||||
@@ -115,3 +115,13 @@
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Edge flow animation — dot traveling from source to target */
|
||||
@keyframes flow-dot {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: -10000; }
|
||||
}
|
||||
.edge-flow-dot {
|
||||
animation: flow-dot 2.5s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ describe('canvasStore', () => {
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
editingGroupRectId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -234,4 +237,95 @@ describe('canvasStore', () => {
|
||||
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
||||
expect(parentIdx).toBeLessThan(childIdx)
|
||||
})
|
||||
|
||||
// --- History (undo/redo) ---
|
||||
|
||||
it('snapshotHistory pushes current state to past and clears future', () => {
|
||||
const { addNode, snapshotHistory } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
const { past, future } = useCanvasStore.getState()
|
||||
expect(past).toHaveLength(1)
|
||||
expect(past[0].nodes).toHaveLength(1)
|
||||
expect(future).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('undo restores previous state and moves current to future', () => {
|
||||
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n2'))
|
||||
undo()
|
||||
const { nodes, past, future } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0].id).toBe('n1')
|
||||
expect(past).toHaveLength(0)
|
||||
expect(future).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('redo re-applies undone state', () => {
|
||||
const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n2'))
|
||||
undo()
|
||||
redo()
|
||||
const { nodes, future } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(future).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('undo does nothing when past is empty', () => {
|
||||
const { addNode, undo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
undo()
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('snapshotHistory clears future (new branch)', () => {
|
||||
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n2'))
|
||||
undo()
|
||||
// now take a new action
|
||||
snapshotHistory()
|
||||
addNode(makeNode('n3'))
|
||||
expect(useCanvasStore.getState().future).toHaveLength(0)
|
||||
})
|
||||
|
||||
// --- Clipboard (copy/paste) ---
|
||||
|
||||
it('copySelectedNodes stores only selected nodes', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{ ...makeNode('a'), selected: true },
|
||||
{ ...makeNode('b'), selected: false },
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
const { clipboard } = useCanvasStore.getState()
|
||||
expect(clipboard).toHaveLength(1)
|
||||
expect(clipboard[0].id).toBe('a')
|
||||
})
|
||||
|
||||
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
||||
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
||||
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(2)
|
||||
const pasted = nodes.find((n) => n.id !== 'src')!
|
||||
expect(pasted).toBeDefined()
|
||||
expect(pasted.position.x).toBe(150)
|
||||
expect(pasted.position.y).toBe(150)
|
||||
expect(pasted.selected).toBe(false)
|
||||
})
|
||||
|
||||
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
addEdge,
|
||||
} from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
|
||||
interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
@@ -18,6 +21,18 @@ interface CanvasState {
|
||||
selectedNodeId: string | null
|
||||
scanEventTs: number
|
||||
|
||||
// History
|
||||
past: HistoryEntry[]
|
||||
future: HistoryEntry[]
|
||||
snapshotHistory: () => void
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
|
||||
// Clipboard
|
||||
clipboard: Node<NodeData>[]
|
||||
copySelectedNodes: () => void
|
||||
pasteNodes: () => void
|
||||
|
||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||
onConnect: (connection: Connection) => void
|
||||
@@ -48,6 +63,67 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
hideIp: false,
|
||||
scanEventTs: 0,
|
||||
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
|
||||
snapshotHistory: () =>
|
||||
set((state) => ({
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
})),
|
||||
|
||||
undo: () =>
|
||||
set((state) => {
|
||||
if (state.past.length === 0) return state
|
||||
const previous = state.past[state.past.length - 1]
|
||||
return {
|
||||
nodes: previous.nodes,
|
||||
edges: previous.edges,
|
||||
past: state.past.slice(0, -1),
|
||||
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
}),
|
||||
|
||||
redo: () =>
|
||||
set((state) => {
|
||||
if (state.future.length === 0) return state
|
||||
const next = state.future[0]
|
||||
return {
|
||||
nodes: next.nodes,
|
||||
edges: next.edges,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: state.future.slice(1),
|
||||
hasUnsavedChanges: true,
|
||||
}
|
||||
}),
|
||||
|
||||
copySelectedNodes: () =>
|
||||
set((state) => ({
|
||||
clipboard: state.nodes.filter((n) => n.selected),
|
||||
})),
|
||||
|
||||
pasteNodes: () =>
|
||||
set((state) => {
|
||||
if (state.clipboard.length === 0) return state
|
||||
const newNodes = state.clipboard.map((n) => ({
|
||||
...n,
|
||||
id: 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) =>
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes),
|
||||
|
||||
@@ -13,6 +13,7 @@ export type NodeType =
|
||||
| 'printer'
|
||||
| 'computer'
|
||||
| 'cpl'
|
||||
| 'docker'
|
||||
| 'generic'
|
||||
| 'groupRect'
|
||||
|
||||
@@ -55,6 +56,11 @@ export interface NodeData extends Record<string, unknown> {
|
||||
last_seen?: string
|
||||
response_time_ms?: number
|
||||
notes?: string
|
||||
cpu_count?: number
|
||||
cpu_model?: string
|
||||
ram_gb?: number
|
||||
disk_gb?: number
|
||||
show_hardware?: boolean
|
||||
parent_id?: string
|
||||
container_mode?: boolean
|
||||
custom_colors?: {
|
||||
@@ -65,6 +71,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
text_color?: string
|
||||
text_position?: TextPosition
|
||||
font?: string
|
||||
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||
z_order?: number
|
||||
width?: number
|
||||
height?: number
|
||||
@@ -81,6 +88,7 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
speed?: string
|
||||
custom_color?: string
|
||||
path_style?: EdgePathStyle
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
@@ -98,6 +106,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
printer: 'Printer',
|
||||
computer: 'Computer',
|
||||
cpl: 'CPL / Powerline',
|
||||
docker: 'Docker Host',
|
||||
generic: 'Generic Device',
|
||||
groupRect: 'Group Rectangle',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => {
|
||||
expect(keys).toContain('play') // Jellyfin
|
||||
expect(keys).toContain('shield') // Pi-hole
|
||||
expect(keys).toContain('anchor') // Portainer
|
||||
expect(keys).toContain('package') // Docker Host
|
||||
expect(keys).toContain('key') // Vaultwarden
|
||||
expect(keys).toContain('database') // DB services
|
||||
expect(keys).toContain('cctv') // IP Camera / CCTV
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||
|
||||
const NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker', 'generic', 'groupRect',
|
||||
]
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||
@@ -84,6 +84,7 @@ describe('THEMES', () => {
|
||||
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
||||
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
||||
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
||||
expect(d.nodeAccents.docker.border).toBe('#2496ED')
|
||||
expect(d.nodeCardBackground).toBe('#21262d')
|
||||
expect(d.nodeIconBackground).toBe('#161b22')
|
||||
expect(d.canvasBackground).toBe('#0d1117')
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
// Transfers & sync
|
||||
Download, Upload, RefreshCw,
|
||||
// Containers & Dev
|
||||
Anchor, GitBranch, Terminal, Code2, Settings,
|
||||
Anchor, Package, GitBranch, Terminal, Code2, Settings,
|
||||
// Communications
|
||||
Mail, MessageSquare, Phone,
|
||||
// Misc devices
|
||||
@@ -98,6 +98,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
||||
|
||||
// --- Containers & Dev ---
|
||||
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
||||
{ key: 'package', label: 'Docker Host', category: 'Dev & Containers', icon: Package },
|
||||
{ key: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
|
||||
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
||||
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
|
||||
|
||||
@@ -56,6 +56,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
||||
},
|
||||
@@ -109,6 +110,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
||||
},
|
||||
@@ -162,6 +164,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
cpl: { border: '#b45309', icon: '#b45309' },
|
||||
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
||||
},
|
||||
@@ -215,6 +218,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||
docker: { border: '#00aaff', icon: '#00aaff' },
|
||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
||||
},
|
||||
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
printer: { border: '#005500', icon: '#005500' },
|
||||
computer: { border: '#008822', icon: '#008822' },
|
||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||
docker: { border: '#00cc88', icon: '#00cc88' },
|
||||
generic: { border: '#006600', icon: '#006600' },
|
||||
groupRect:{ border: '#00ff41', icon: '#00ff41' },
|
||||
},
|
||||
|
||||
@@ -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/*"]
|
||||
}
|
||||
},
|
||||
"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