Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b5317a0c | |||
| 0f643477f6 | |||
| 861d2822b9 | |||
| 059bb3daa7 | |||
| c01d87381d | |||
| ec0519d2b7 | |||
| 1182dbd82d | |||
| 821e324111 | |||
| ea3adc0f94 | |||
| 6f8f0d5e8f | |||
| daf3f59590 | |||
| 212eb37e34 | |||
| 61b8a210fe | |||
| a43ffb813e | |||
| f469d6c744 | |||
| e7ab9a1d7a | |||
| d5b67a770c | |||
| 2e49c14028 | |||
| d9787fdcbb | |||
| 06ec18a137 | |||
| adb4474687 | |||
| 2008f9467a | |||
| d9ac9462a8 | |||
| e14a9e87aa | |||
| e5d7260696 | |||
| df3b7a8cb0 | |||
| 426af29180 | |||
| f36bdfe878 | |||
| 300567c88d | |||
| e41dbe579c | |||
| e1d16b86e3 | |||
| 593335648f | |||
| e3f8c27a04 | |||
| ff69856d31 | |||
| 7935b671d3 |
@@ -15,3 +15,10 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
|||||||
|
|
||||||
# Status checker interval in seconds
|
# Status checker interval in seconds
|
||||||
STATUS_CHECKER_INTERVAL=60
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
|
||||||
|
# MCP server — used by the mcp service (port 8001)
|
||||||
|
# MCP_API_KEY: authenticates AI clients (Claude Code, etc.) → MCP server
|
||||||
|
# MCP_SERVICE_KEY: authenticates MCP server → backend (never exposed externally)
|
||||||
|
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
|
MCP_SERVICE_KEY=svc_changeme
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ htmlcov/
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
Ideas.md
|
||||||
|
|||||||
+2
-2
@@ -2,8 +2,8 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install nmap for network scanning
|
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# Homelable — Installation
|
||||||
|
|
||||||
|
## Quick Start — Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
||||||
|
cd homelable && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:3000** — login with `admin` / `admin`.
|
||||||
|
|
||||||
|
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
||||||
|
>
|
||||||
|
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
|
||||||
|
>
|
||||||
|
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
|
||||||
|
|
||||||
|
## Quick Start — Frontend only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
|
||||||
|
cd homelable && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update (Docker)
|
||||||
|
|
||||||
|
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
||||||
|
cd homelable && docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Pouzor/homelable.git
|
||||||
|
cd homelable
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proxmox LXC Install
|
||||||
|
|
||||||
|
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
||||||
|
|
||||||
|
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
||||||
|
> ```bash
|
||||||
|
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### Update (LXC)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
All configuration is done via `.env` (copied from `.env.example`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
SECRET_KEY=change_me_in_production
|
||||||
|
|
||||||
|
# Auth — default: admin / admin
|
||||||
|
AUTH_USERNAME=admin
|
||||||
|
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
|
||||||
|
|
||||||
|
# CIDR ranges to scan
|
||||||
|
SCANNER_RANGES=["192.168.1.0/24"]
|
||||||
|
|
||||||
|
# How often to check node status (seconds)
|
||||||
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
```
|
||||||
|
|
||||||
|
All settings are also editable in-app via the **Scan Network** button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
**Backend (Python 3.13):**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3.13 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp ../.env.example .env # edit SECRET_KEY and review defaults
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
@@ -16,95 +16,15 @@ If you just like the design, you can only run the frontend and export your desig
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
<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/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
|
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
|
||||||
|
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Installation
|
||||||
|
|
||||||
```bash
|
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
|
||||||
cd homelable && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open **http://localhost:3000** — login with `admin` / `admin`.
|
|
||||||
|
|
||||||
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
|
||||||
>
|
|
||||||
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
|
|
||||||
>
|
|
||||||
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
|
|
||||||
|
|
||||||
## Quick Start - Front only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
|
|
||||||
cd homelable && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
|
||||||
cd homelable && docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Pouzor/homelable.git
|
|
||||||
cd homelable
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxmox LXC Install
|
|
||||||
|
|
||||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
|
||||||
|
|
||||||
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
|
||||||
> ```bash
|
|
||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
> ```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration is done via `.env` (copied from `.env.example`):
|
|
||||||
|
|
||||||
```env
|
|
||||||
SECRET_KEY=change_me_in_production
|
|
||||||
|
|
||||||
# Auth — default: admin / admin
|
|
||||||
AUTH_USERNAME=admin
|
|
||||||
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
|
|
||||||
|
|
||||||
# CIDR ranges to scan
|
|
||||||
SCANNER_RANGES=["192.168.1.0/24"]
|
|
||||||
|
|
||||||
# How often to check node status (seconds)
|
|
||||||
STATUS_CHECKER_INTERVAL=60
|
|
||||||
```
|
|
||||||
|
|
||||||
All settings are also editable in-app via the **Scan Network** button.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,7 +34,8 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
|
|||||||
|
|
||||||
### Triggering a scan
|
### Triggering a scan
|
||||||
|
|
||||||
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes. Errors are shown inline and as a toast notification.
|
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
||||||
|
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
||||||
|
|
||||||
### macOS / root privileges
|
### macOS / root privileges
|
||||||
|
|
||||||
@@ -137,19 +58,10 @@ Results are written directly to the database and appear as Pending Devices in th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Proxmox Nested Nodes
|
|
||||||
|
|
||||||
Proxmox nodes render as a resizable group container. VM and LXC nodes can be placed inside:
|
|
||||||
|
|
||||||
1. Add a **Proxmox VE** node to the canvas
|
|
||||||
2. Add a **VM** or **LXC** node — select the Proxmox node in the **Parent Proxmox** dropdown
|
|
||||||
3. The child node appears inside the group and moves with it
|
|
||||||
4. Select the Proxmox node to reveal resize handles (drag corners to expand)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Node Check Methods
|
## Node Check Methods
|
||||||
|
|
||||||
|
Homelable continuously monitors your nodes and displays their live status (online / offline / unknown) directly on the canvas. Each node can be configured with an independent check method suited to the service it runs.
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `ping` | ICMP ping |
|
| `ping` | ICMP ping |
|
||||||
@@ -162,23 +74,90 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Mode
|
## MCP Server (AI Integration) (optionnal)
|
||||||
|
|
||||||
**Backend (Python 3.13):**
|
Homelable can 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.
|
||||||
```bash
|
|
||||||
cd backend
|
### What the AI can do
|
||||||
python3.13 -m venv .venv && source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
| | Action |
|
||||||
cp ../.env.example .env # edit SECRET_KEY and review defaults
|
|---|---|
|
||||||
uvicorn app.main:app --reload --port 8000
|
| **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))"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Frontend:**
|
No plain-text passwords involved — `AUTH_PASSWORD_HASH` is only used for the web UI login.
|
||||||
|
|
||||||
|
**2. Start the MCP service:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
docker compose up -d mcp
|
||||||
npm install
|
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+21
-3
@@ -1,12 +1,30 @@
|
|||||||
from fastapi import Depends, HTTPException, status
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
|
|
||||||
bearer = HTTPBearer()
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
|
def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
x_mcp_service_key: str | None = Header(default=None),
|
||||||
|
) -> str:
|
||||||
|
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||||
|
if x_mcp_service_key is not None:
|
||||||
|
if not settings.mcp_service_key:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||||
|
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||||
|
return "__mcp_service__"
|
||||||
|
|
||||||
|
# 2. Standard JWT bearer token
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
username = decode_token(credentials.credentials)
|
username = decode_token(credentials.credentials)
|
||||||
if not username:
|
if not username:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|||||||
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
|
|||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket, token: str | None = None) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
|
# 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):
|
if not token or not decode_token(token):
|
||||||
await websocket.close(code=1008) # Policy Violation
|
await websocket.close(code=1008) # Policy Violation
|
||||||
return
|
return
|
||||||
await websocket.accept()
|
except WebSocketDisconnect:
|
||||||
|
return
|
||||||
|
|
||||||
_connections.append(websocket)
|
_connections.append(websocket)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class Settings(BaseSettings):
|
|||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
|
# Leave empty to disable MCP service key auth.
|
||||||
|
mcp_service_key: str = ""
|
||||||
|
|
||||||
def _override_path(self) -> Path:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
[
|
||||||
|
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
|
||||||
|
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
|
||||||
|
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
|
||||||
|
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
|
||||||
|
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
||||||
|
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
||||||
|
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
|
||||||
|
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
|
||||||
|
|
||||||
|
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
||||||
|
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
|
||||||
|
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
|
||||||
|
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
|
||||||
|
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
||||||
|
]
|
||||||
@@ -42,6 +42,16 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
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]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class Node(Base):
|
|||||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
custom_icon: Mapped[str | None] = mapped_column(String, 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))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.0.0",
|
version="1.3.3",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ class NodeSave(BaseModel):
|
|||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | 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_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class NodeBase(BaseModel):
|
|||||||
container_mode: bool = False
|
container_mode: bool = False
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | 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):
|
class NodeCreate(NodeBase):
|
||||||
@@ -42,9 +47,15 @@ class NodeUpdate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
pos_x: float | None = None
|
pos_x: float | None = None
|
||||||
pos_y: float | None = None
|
pos_y: float | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
container_mode: bool | None = None
|
container_mode: bool | None = None
|
||||||
custom_colors: dict[str, Any] | None = None
|
custom_colors: dict[str, Any] | None = None
|
||||||
custom_icon: str | None = None
|
custom_icon: str | None = None
|
||||||
|
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):
|
class NodeResponse(NodeBase):
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
"""Match nmap scan results against service_signatures.json."""
|
"""Match nmap scan results against service_signatures.json."""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SIGNATURES: list[dict[str, Any]] | None = None
|
_SIGNATURES: list[dict[str, Any]] | None = None
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _load() -> list[dict[str, Any]]:
|
def _load() -> list[dict[str, Any]]:
|
||||||
global _SIGNATURES
|
global _SIGNATURES
|
||||||
if _SIGNATURES is None:
|
if _SIGNATURES is None:
|
||||||
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json"
|
with _LOCK:
|
||||||
|
if _SIGNATURES is None:
|
||||||
|
path = Path(__file__).parent.parent / "data" / "service_signatures.json"
|
||||||
|
try:
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
_SIGNATURES = json.load(f)
|
_SIGNATURES = json.load(f)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"service_signatures.json not found at {path}. "
|
||||||
|
"This file should be bundled with the application."
|
||||||
|
) from err
|
||||||
return _SIGNATURES
|
return _SIGNATURES
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
@@ -28,3 +29,30 @@ async def test_health_is_public(client: AsyncClient):
|
|||||||
res = await client.get("/api/v1/health")
|
res = await client.get("/api/v1/health")
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json() == {"status": "ok"}
|
assert res.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- MCP service key auth ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def with_service_key():
|
||||||
|
from app.core.config import settings
|
||||||
|
settings.mcp_service_key = "test-service-key"
|
||||||
|
yield "test-service-key"
|
||||||
|
settings.mcp_service_key = ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_key_grants_access(client: AsyncClient, with_service_key):
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": with_service_key})
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_key_wrong_value(client: AsyncClient, with_service_key):
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "wrong-key"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
||||||
|
from app.core.config import settings
|
||||||
|
settings.mcp_service_key = ""
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -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):
|
async def test_save_canvas_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
res = await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}})
|
||||||
assert res.status_code == 401
|
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
|
assert res.json()["container_mode"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||||
|
parent = await client.post("/api/v1/nodes", json={"type": "proxmox", "label": "PVE", "status": "unknown"}, headers=headers)
|
||||||
|
parent_id = parent.json()["id"]
|
||||||
|
child = await client.post("/api/v1/nodes", json={"type": "lxc", "label": "Child", "status": "unknown"}, headers=headers)
|
||||||
|
child_id = child.json()["id"]
|
||||||
|
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": parent_id}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["parent_id"] == parent_id
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_requires_auth(client: AsyncClient):
|
async def test_create_node_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|||||||
@@ -22,24 +22,33 @@ def _make_token() -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def test_websocket_rejected_without_token():
|
def test_websocket_rejected_without_token():
|
||||||
"""Connection with no token must be closed before being accepted."""
|
"""Connection that sends no token field must be closed with 1008."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({})) # missing token field
|
||||||
|
ws.receive_text() # triggers WebSocketDisconnect from server close
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_rejected_with_invalid_token():
|
def test_websocket_rejected_with_invalid_token():
|
||||||
"""Connection with a garbage token must be closed."""
|
"""Connection that sends a garbage token must be closed."""
|
||||||
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status?token=not-a-valid-jwt"):
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
pass
|
ws.send_text(json.dumps({"token": "not-a-valid-jwt"}))
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_websocket_rejected_with_malformed_json():
|
||||||
|
"""Connection that sends non-JSON as auth must be closed."""
|
||||||
|
with TestClient(app) as client, pytest.raises(WebSocketDisconnect), client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
|
ws.send_text("not-json")
|
||||||
|
ws.receive_text()
|
||||||
|
|
||||||
|
|
||||||
def test_websocket_accepted_with_valid_token():
|
def test_websocket_accepted_with_valid_token():
|
||||||
"""Connection with a valid JWT must be accepted and kept open."""
|
"""Connection that sends a valid JWT as first message must be accepted."""
|
||||||
token = _make_token()
|
token = _make_token()
|
||||||
with TestClient(app) as client, client.websocket_connect(f"/api/v1/status/ws/status?token={token}") as ws:
|
with TestClient(app) as client, client.websocket_connect("/api/v1/status/ws/status") as ws:
|
||||||
# Connection is open — we can send a ping and it should not raise
|
ws.send_text(json.dumps({"token": token}))
|
||||||
|
# Connection is open — subsequent messages should not raise
|
||||||
ws.send_text("ping")
|
ws.send_text("ping")
|
||||||
# Server keeps the connection open (no disconnect expected)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -18,6 +18,22 @@ services:
|
|||||||
cap_add:
|
cap_add:
|
||||||
- NET_RAW
|
- NET_RAW
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
build:
|
||||||
|
context: ./mcp
|
||||||
|
dockerfile: Dockerfile.mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: "http://backend:8000"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- homelable
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
+11
-1
@@ -4,6 +4,16 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Proxy WebSocket (must be before /api/ to take priority)
|
||||||
|
location /api/v1/status/ws/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy API to backend
|
# Proxy API to backend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
@@ -11,7 +21,7 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy WebSocket
|
# Proxy legacy /ws/ path
|
||||||
location /ws/ {
|
location /ws/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 614 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
Generated
+168
-124
@@ -1,24 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -40,7 +42,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
@@ -1482,6 +1484,37 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint/config-helpers": {
|
"node_modules/@eslint/config-helpers": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
|
||||||
@@ -1532,6 +1565,24 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||||
"version": "14.0.0",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||||
@@ -1545,6 +1596,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.39.4",
|
"version": "9.39.4",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
|
||||||
@@ -2838,42 +2902,6 @@
|
|||||||
"path-browserify": "^1.0.1"
|
"path-browserify": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
|
||||||
"version": "10.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
|
||||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/aria-query": {
|
"node_modules/@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
@@ -3008,6 +3036,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-yaml": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -3245,45 +3279,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
|
||||||
"version": "10.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
|
||||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^5.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -3339,19 +3334,6 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://opencollective.com/eslint"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz",
|
||||||
@@ -3812,11 +3794,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dev": true,
|
"license": "MIT",
|
||||||
"license": "MIT"
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.0",
|
"version": "2.10.0",
|
||||||
@@ -3865,14 +3849,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^4.0.2"
|
||||||
"concat-map": "0.0.1"
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
@@ -5017,6 +5002,37 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-visitor-keys": {
|
"node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/balanced-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/brace-expansion": {
|
||||||
|
"version": "1.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^1.0.0",
|
||||||
|
"concat-map": "0.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
@@ -5029,6 +5045,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint/node_modules/minimatch": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"brace-expansion": "^1.1.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/espree": {
|
"node_modules/espree": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||||
@@ -5047,6 +5076,19 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/espree/node_modules/eslint-visitor-keys": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/eslint"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esprima": {
|
"node_modules/esprima": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||||
@@ -5474,9 +5516,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatted": {
|
"node_modules/flatted": {
|
||||||
"version": "3.3.4",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||||
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -5826,9 +5868,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.5",
|
"version": "4.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.8.tgz",
|
||||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
"integrity": "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -6917,9 +6959,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
@@ -6981,16 +7023,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "10.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||||
"dev": true,
|
"license": "BlueOak-1.0.0",
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^1.1.7"
|
"brace-expansion": "^5.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "*"
|
"node": "18 || 20 || >=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimist": {
|
"node_modules/minimist": {
|
||||||
@@ -7493,9 +7537,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -8770,9 +8814,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "7.22.0",
|
"version": "7.24.3",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
|
||||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,12 +19,14 @@
|
|||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.0",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
|||||||
+35
-3
@@ -2,8 +2,11 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
@@ -31,7 +34,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
@@ -102,6 +105,11 @@ export default function App() {
|
|||||||
container_mode: n.data.container_mode ?? false,
|
container_mode: n.data.container_mode ?? false,
|
||||||
custom_colors: n.data.custom_colors ?? null,
|
custom_colors: n.data.custom_colors ?? null,
|
||||||
custom_icon: n.data.custom_icon ?? 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_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}
|
}
|
||||||
@@ -238,7 +246,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = generateUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isProxmox = data.type === 'proxmox'
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
// Children position is relative to parent; place near top-left with padding
|
// Children position is relative to parent; place near top-left with padding
|
||||||
@@ -260,7 +268,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = generateUUID()
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
type: 'groupRect',
|
type: 'groupRect',
|
||||||
@@ -272,6 +280,7 @@ export default function App() {
|
|||||||
services: [],
|
services: [],
|
||||||
custom_colors: {
|
custom_colors: {
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
@@ -294,6 +303,7 @@ export default function App() {
|
|||||||
custom_colors: {
|
custom_colors: {
|
||||||
...existing?.data.custom_colors,
|
...existing?.data.custom_colors,
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
@@ -363,6 +373,25 @@ export default function App() {
|
|||||||
toast.success('Markdown table copied to clipboard')
|
toast.success('Markdown table copied to clipboard')
|
||||||
}, [nodes])
|
}, [nodes])
|
||||||
|
|
||||||
|
const handleExportYaml = useCallback(() => {
|
||||||
|
if (nodes.length === 0) { toast.error('No nodes to export'); return }
|
||||||
|
const content = exportCanvasToYaml(nodes, edges)
|
||||||
|
downloadYaml(content)
|
||||||
|
toast.success('Canvas exported as YAML')
|
||||||
|
}, [nodes, edges])
|
||||||
|
|
||||||
|
const handleImportYaml = useCallback((content: string) => {
|
||||||
|
try {
|
||||||
|
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
||||||
|
snapshotHistory()
|
||||||
|
loadCanvas(merged, mergedEdges)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -441,6 +470,8 @@ export default function App() {
|
|||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
onShortcuts={() => setShortcutsOpen(true)}
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
onExportMd={handleExportMd}
|
onExportMd={handleExportMd}
|
||||||
|
onExportYaml={handleExportYaml}
|
||||||
|
onImportYaml={handleImportYaml}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
@@ -524,6 +555,7 @@ export default function App() {
|
|||||||
text_color: rc.text_color ?? '#e6edf3',
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
text_position: rc.text_position ?? 'top-left',
|
text_position: rc.text_position ?? 'top-left',
|
||||||
border_color: rc.border ?? '#00d4ff',
|
border_color: rc.border ?? '#00d4ff',
|
||||||
|
border_style: rc.border_style ?? 'solid',
|
||||||
background_color: rc.background ?? '#00d4ff0d',
|
background_color: rc.background ?? '#00d4ff0d',
|
||||||
z_order: rc.z_order ?? 1,
|
z_order: rc.z_order ?? 1,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { Handle, Position, type NodeProps, type Node } from '@xyflow/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 type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||||
@@ -13,6 +13,11 @@ interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
|||||||
icon: LucideIcon
|
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) {
|
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
@@ -22,10 +27,11 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
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 (
|
return (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
@@ -47,6 +53,8 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
|
{/* Main row */}
|
||||||
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
|
||||||
@@ -58,7 +66,7 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
{createElement(resolvedIcon, { size: 15 })}
|
{createElement(resolvedIcon, { size: 15 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Label + IP */}
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<div
|
<div
|
||||||
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
className="text-xs font-medium leading-tight truncate max-w-[110px]"
|
||||||
@@ -77,6 +85,45 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
const borderColor = rc.border ?? '#00d4ff'
|
||||||
|
const borderStyle = rc.border_style ?? 'solid'
|
||||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||||
const textColor = rc.text_color ?? '#e6edf3'
|
const textColor = rc.text_color ?? '#e6edf3'
|
||||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
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,
|
justifyContent: posStyle.justifyContent,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: backgroundColor,
|
background: backgroundColor,
|
||||||
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
|
border: `${selected ? 2 : 1}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
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'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
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 PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
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} />
|
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 { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ export const nodeTypes = {
|
|||||||
printer: PrinterNode,
|
printer: PrinterNode,
|
||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
|
docker: DockerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,34 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import type { TextPosition } from '@/types'
|
import type { TextPosition } from '@/types'
|
||||||
|
|
||||||
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
export interface GroupRectFormData {
|
export interface GroupRectFormData {
|
||||||
label: string
|
label: string
|
||||||
font: string
|
font: string
|
||||||
text_color: string
|
text_color: string
|
||||||
text_position: TextPosition
|
text_position: TextPosition
|
||||||
border_color: string
|
border_color: string
|
||||||
|
border_style: BorderStyle
|
||||||
background_color: string
|
background_color: string
|
||||||
z_order: number
|
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 = {
|
const DEFAULT_FORM: GroupRectFormData = {
|
||||||
label: '',
|
label: '',
|
||||||
font: 'inter',
|
font: 'inter',
|
||||||
text_color: '#e6edf3',
|
text_color: '#e6edf3',
|
||||||
text_position: 'top-left',
|
text_position: 'top-left',
|
||||||
border_color: '#00d4ff',
|
border_color: '#00d4ff',
|
||||||
|
border_style: 'solid',
|
||||||
background_color: '#00d4ff0d',
|
background_color: '#00d4ff0d',
|
||||||
z_order: 1,
|
z_order: 1,
|
||||||
}
|
}
|
||||||
@@ -157,6 +169,33 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Z-order */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
<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 { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
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 { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
|
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']
|
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 [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
|
const [labelError, setLabelError] = useState(false)
|
||||||
|
const 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) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.label?.trim()) return
|
if (!form.label?.trim()) {
|
||||||
|
setLabelError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLabelError(false)
|
||||||
onSubmit(form)
|
onSubmit(form)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -71,11 +83,21 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{NODE_TYPES.map(([value, label]) => (
|
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||||
<SelectItem key={value} value={value} className="text-sm">
|
<>
|
||||||
{label}
|
{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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,11 +189,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Label className="text-xs text-muted-foreground">Label *</Label>
|
<Label className="text-xs text-muted-foreground">Label *</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.label ?? ''}
|
value={form.label ?? ''}
|
||||||
onChange={(e) => set('label', e.target.value)}
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||||
placeholder="My Server"
|
placeholder="My Server"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hostname */}
|
{/* Hostname */}
|
||||||
@@ -310,6 +332,88 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Notes */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
|||||||
@@ -80,4 +80,56 @@ describe('GroupRectModal', () => {
|
|||||||
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
expect(submitted.text_position).toBe('bottom-right')
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -100,6 +100,17 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Services */}
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@@ -202,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 }) {
|
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } from 'lucide-react'
|
import { useRef } from 'react'
|
||||||
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -12,10 +13,25 @@ interface ToolbarProps {
|
|||||||
onRedo: () => void
|
onRedo: () => void
|
||||||
onShortcuts: () => void
|
onShortcuts: () => void
|
||||||
onExportMd: () => void
|
onExportMd: () => void
|
||||||
|
onExportYaml: () => void
|
||||||
|
onImportYaml: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const content = ev.target?.result
|
||||||
|
if (typeof content === 'string') onImportYaml(content)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
|
||||||
@@ -46,9 +62,22 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Export as PNG">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
|
<Upload size={14} /> Import
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".yaml,.yml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
|
||||||
|
<FileDown size={14} /> PNG
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
<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
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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
|
if (STANDALONE || !isAuthenticated || !token) return
|
||||||
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
const host = window.location.hostname
|
const host = window.location.host // includes port when non-standard
|
||||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status?token=${encodeURIComponent(token)}`
|
const url = `${protocol}://${host}/api/v1/status/ws/status`
|
||||||
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
|
|
||||||
|
// Send token as first message (not in URL to avoid log/history exposure)
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({ token }))
|
||||||
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg: StatusMessage = JSON.parse(event.data)
|
const msg: StatusMessage = JSON.parse(event.data)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
addEdge,
|
addEdge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
if (state.clipboard.length === 0) return state
|
if (state.clipboard.length === 0) return state
|
||||||
const newNodes = state.clipboard.map((n) => ({
|
const newNodes = state.clipboard.map((n) => ({
|
||||||
...n,
|
...n,
|
||||||
id: crypto.randomUUID(),
|
id: generateUUID(),
|
||||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||||
selected: false,
|
selected: false,
|
||||||
parentId: undefined,
|
parentId: undefined,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type NodeType =
|
|||||||
| 'printer'
|
| 'printer'
|
||||||
| 'computer'
|
| 'computer'
|
||||||
| 'cpl'
|
| 'cpl'
|
||||||
|
| 'docker'
|
||||||
| 'generic'
|
| 'generic'
|
||||||
| 'groupRect'
|
| 'groupRect'
|
||||||
|
|
||||||
@@ -55,6 +56,11 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
last_seen?: string
|
last_seen?: string
|
||||||
response_time_ms?: number
|
response_time_ms?: number
|
||||||
notes?: string
|
notes?: string
|
||||||
|
cpu_count?: number
|
||||||
|
cpu_model?: string
|
||||||
|
ram_gb?: number
|
||||||
|
disk_gb?: number
|
||||||
|
show_hardware?: boolean
|
||||||
parent_id?: string
|
parent_id?: string
|
||||||
container_mode?: boolean
|
container_mode?: boolean
|
||||||
custom_colors?: {
|
custom_colors?: {
|
||||||
@@ -65,6 +71,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
text_color?: string
|
text_color?: string
|
||||||
text_position?: TextPosition
|
text_position?: TextPosition
|
||||||
font?: string
|
font?: string
|
||||||
|
border_style?: 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
z_order?: number
|
z_order?: number
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -99,6 +106,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
printer: 'Printer',
|
printer: 'Printer',
|
||||||
computer: 'Computer',
|
computer: 'Computer',
|
||||||
cpl: 'CPL / Powerline',
|
cpl: 'CPL / Powerline',
|
||||||
|
docker: 'Docker Host',
|
||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
groupRect: 'Group Rectangle',
|
groupRect: 'Group Rectangle',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NodeType, EdgeType, CheckMethod } from '@/types'
|
||||||
|
|
||||||
|
export interface YamlNodeConnection {
|
||||||
|
label: string
|
||||||
|
linkType?: EdgeType
|
||||||
|
linkLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YamlNode {
|
||||||
|
nodeType: NodeType
|
||||||
|
nodeIcon?: string
|
||||||
|
label: string
|
||||||
|
hostname?: string
|
||||||
|
ipAddress?: string
|
||||||
|
checkMethod?: CheckMethod
|
||||||
|
checkTarget?: string
|
||||||
|
notes?: string
|
||||||
|
links?: YamlNodeConnection[]
|
||||||
|
parent?: YamlNodeConnection
|
||||||
|
clusterR?: YamlNodeConnection
|
||||||
|
clusterL?: YamlNodeConnection
|
||||||
|
cpuModel?: string
|
||||||
|
cpuCore?: number
|
||||||
|
ram?: number
|
||||||
|
disk?: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { exportCanvasToYaml } from '../exportYaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<NodeData> = {}, id = '1', parentId?: string): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
parentId,
|
||||||
|
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeEdge = (id: string, source: string, target: string, data: Partial<EdgeData> = {}): Edge<EdgeData> => ({
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exportCanvasToYaml', () => {
|
||||||
|
it('serializes a simple node with basic fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'My Server', type: 'server', ip: '192.168.1.10', hostname: 'srv.local' })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry.nodeType).toBe('server')
|
||||||
|
expect(entry.label).toBe('My Server')
|
||||||
|
expect(entry.ipAddress).toBe('192.168.1.10')
|
||||||
|
expect(entry.hostname).toBe('srv.local')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits empty/null/undefined optional fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Router', type: 'router', hostname: undefined, ip: undefined, notes: undefined })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry).not.toHaveProperty('hostname')
|
||||||
|
expect(entry).not.toHaveProperty('ipAddress')
|
||||||
|
expect(entry).not.toHaveProperty('notes')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits hardware specs when zero or falsy', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 0, ram_gb: 0, disk_gb: 0 })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry).not.toHaveProperty('cpuCore')
|
||||||
|
expect(entry).not.toHaveProperty('ram')
|
||||||
|
expect(entry).not.toHaveProperty('disk')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes hardware specs when non-zero', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Server', type: 'server', cpu_count: 16, ram_gb: 64, disk_gb: 2000, cpu_model: 'Intel Xeon' })]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
const entry = result[0] as Record<string, unknown>
|
||||||
|
expect(entry.cpuCore).toBe(16)
|
||||||
|
expect(entry.ram).toBe(64)
|
||||||
|
expect(entry.disk).toBe(2000)
|
||||||
|
expect(entry.cpuModel).toBe('Intel Xeon')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes parent relationship from parentId', () => {
|
||||||
|
const parent = makeNode({ label: 'Proxmox1', type: 'proxmox' }, 'pve1')
|
||||||
|
const child = makeNode({ label: 'VM1', type: 'vm' }, 'vm1', 'pve1')
|
||||||
|
const edge = makeEdge('e1', 'pve1', 'vm1', { type: 'virtual' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([parent, child], [edge])) as object[]
|
||||||
|
const childEntry = (result as Record<string, unknown>[]).find((e) => e.label === 'VM1')!
|
||||||
|
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes cluster-type edge as clusterR on source node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
|
||||||
|
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
|
||||||
|
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster', label: '10GbE' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'PVE1')!
|
||||||
|
expect(entryA.clusterR).toEqual({ label: 'PVE2', linkType: 'cluster', linkLabel: '10GbE' })
|
||||||
|
expect(entryA).not.toHaveProperty('links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes cluster-type incoming edge as clusterL on target node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
|
||||||
|
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
|
||||||
|
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'PVE1')!
|
||||||
|
const entryB = result.find((e) => e.label === 'PVE2')!
|
||||||
|
// edge serialized as clusterR on A — should NOT also appear as clusterL on B
|
||||||
|
expect(entryA.clusterR).toBeDefined()
|
||||||
|
expect(entryB).not.toHaveProperty('clusterL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes regular ethernet edge in links array on source node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||||
|
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
const edge = makeEdge('e1', 'sw', 's1', { type: 'ethernet', label: 'eth0' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'Switch')!
|
||||||
|
const entryB = result.find((e) => e.label === 'Server1')!
|
||||||
|
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'ethernet', linkLabel: 'eth0' }])
|
||||||
|
expect(entryB).not.toHaveProperty('links')
|
||||||
|
expect(entryA).not.toHaveProperty('clusterR')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes multiple outgoing edges as links array', () => {
|
||||||
|
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||||
|
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||||
|
const s2 = makeNode({ label: 'Server2', type: 'server' }, 's2')
|
||||||
|
const s3 = makeNode({ label: 'Server3', type: 'server' }, 's3')
|
||||||
|
const edges = [
|
||||||
|
makeEdge('e1', 'sw', 's1', { type: 'ethernet' }),
|
||||||
|
makeEdge('e2', 'sw', 's2', { type: 'ethernet' }),
|
||||||
|
makeEdge('e3', 'sw', 's3', { type: 'wifi' }),
|
||||||
|
]
|
||||||
|
const result = yaml.load(exportCanvasToYaml([sw, s1, s2, s3], edges)) as Record<string, unknown>[]
|
||||||
|
const swEntry = result.find((e) => e.label === 'Switch')!
|
||||||
|
const links = swEntry.links as Record<string, unknown>[]
|
||||||
|
expect(links).toHaveLength(3)
|
||||||
|
expect(links.map((l) => l.label)).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3']))
|
||||||
|
// Servers should have no links (edges are on source side)
|
||||||
|
for (const label of ['Server1', 'Server2', 'Server3']) {
|
||||||
|
const entry = result.find((e) => e.label === label)!
|
||||||
|
expect(entry).not.toHaveProperty('links')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not duplicate a links edge on the target node', () => {
|
||||||
|
const nodeA = makeNode({ label: 'NodeA', type: 'server' }, 'a')
|
||||||
|
const nodeB = makeNode({ label: 'NodeB', type: 'server' }, 'b')
|
||||||
|
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' })
|
||||||
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
|
const entryA = result.find((e) => e.label === 'NodeA')!
|
||||||
|
const entryB = result.find((e) => e.label === 'NodeB')!
|
||||||
|
expect(entryA.links).toHaveLength(1)
|
||||||
|
expect(entryB).not.toHaveProperty('links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes from output', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ label: 'Zone', type: 'groupRect' }, '1'),
|
||||||
|
makeNode({ label: 'Server', type: 'server' }, '2'),
|
||||||
|
]
|
||||||
|
const result = yaml.load(exportCanvasToYaml(nodes, [])) as object[]
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect((result[0] as Record<string, unknown>).label).toBe('Server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('roundtrip: all non-empty fields appear in YAML output', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Full Node',
|
||||||
|
type: 'server',
|
||||||
|
ip: '10.0.0.1',
|
||||||
|
hostname: 'full.local',
|
||||||
|
check_method: 'ping',
|
||||||
|
check_target: '10.0.0.1',
|
||||||
|
notes: 'test notes',
|
||||||
|
cpu_model: 'AMD EPYC',
|
||||||
|
cpu_count: 32,
|
||||||
|
ram_gb: 128,
|
||||||
|
disk_gb: 4000,
|
||||||
|
custom_icon: 'star',
|
||||||
|
})]
|
||||||
|
const yamlStr = exportCanvasToYaml(nodes, [])
|
||||||
|
expect(yamlStr).toContain('Full Node')
|
||||||
|
expect(yamlStr).toContain('10.0.0.1')
|
||||||
|
expect(yamlStr).toContain('full.local')
|
||||||
|
expect(yamlStr).toContain('ping')
|
||||||
|
expect(yamlStr).toContain('test notes')
|
||||||
|
expect(yamlStr).toContain('AMD EPYC')
|
||||||
|
expect(yamlStr).toContain('32')
|
||||||
|
expect(yamlStr).toContain('128')
|
||||||
|
expect(yamlStr).toContain('4000')
|
||||||
|
expect(yamlStr).toContain('star')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { parseYamlToCanvas } from '../importYaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
// Mock dagre layout to return nodes with predictable positions
|
||||||
|
vi.mock('../layout', () => ({
|
||||||
|
applyDagreLayout: (nodes: Node<NodeData>[]) =>
|
||||||
|
nodes.map((n, i) => ({ ...n, position: { x: i * 200, y: 0 } })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock uuid to return deterministic ids
|
||||||
|
let uuidCounter = 0
|
||||||
|
vi.mock('../uuid', () => ({
|
||||||
|
generateUUID: () => `test-uuid-${++uuidCounter}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
uuidCounter = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const empty: Node<NodeData>[] = []
|
||||||
|
const emptyEdges: Edge<EdgeData>[] = []
|
||||||
|
|
||||||
|
describe('parseYamlToCanvas', () => {
|
||||||
|
it('parses a minimal node (only nodeType + label)', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "My Server"
|
||||||
|
`
|
||||||
|
const { nodes, edges, imported } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(imported).toBe(1)
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(nodes[0].data.label).toBe('My Server')
|
||||||
|
expect(nodes[0].data.type).toBe('server')
|
||||||
|
expect(nodes[0].data.status).toBe('unknown')
|
||||||
|
expect(edges).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses all scalar fields', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
hostname: "pve1.local"
|
||||||
|
ipAddress: "192.168.1.10"
|
||||||
|
checkMethod: ping
|
||||||
|
checkTarget: "192.168.1.10"
|
||||||
|
notes: "main host"
|
||||||
|
nodeIcon: "custom-icon"
|
||||||
|
cpuModel: "Intel Xeon"
|
||||||
|
cpuCore: 16
|
||||||
|
ram: 64
|
||||||
|
disk: 2000
|
||||||
|
`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const d = nodes[0].data
|
||||||
|
expect(d.hostname).toBe('pve1.local')
|
||||||
|
expect(d.ip).toBe('192.168.1.10')
|
||||||
|
expect(d.check_method).toBe('ping')
|
||||||
|
expect(d.check_target).toBe('192.168.1.10')
|
||||||
|
expect(d.notes).toBe('main host')
|
||||||
|
expect(d.custom_icon).toBe('custom-icon')
|
||||||
|
expect(d.cpu_model).toBe('Intel Xeon')
|
||||||
|
expect(d.cpu_count).toBe(16)
|
||||||
|
expect(d.ram_gb).toBe(64)
|
||||||
|
expect(d.disk_gb).toBe(2000)
|
||||||
|
expect(d.show_hardware).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets show_hardware only when hardware fields present', () => {
|
||||||
|
const yaml = `- nodeType: server\n label: "NoHW"\n`
|
||||||
|
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes[0].data.show_hardware).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parent relationship sets parentId and creates an edge', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
- nodeType: vm
|
||||||
|
label: "VM1"
|
||||||
|
parent:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: virtual
|
||||||
|
linkLabel: "hosted"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const vm = nodes.find((n) => n.data.label === 'VM1')!
|
||||||
|
const pve = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
expect(vm.parentId).toBe(pve.id)
|
||||||
|
expect(vm.data.parent_id).toBe(pve.id)
|
||||||
|
expect(vm.extent).toBe('parent')
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve.id)
|
||||||
|
expect(edges[0].target).toBe(vm.id)
|
||||||
|
expect(edges[0].type).toBe('virtual')
|
||||||
|
expect(edges[0].data?.label).toBe('hosted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clusterR creates an edge from this node to target', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
linkLabel: "10GbE"
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve1.id)
|
||||||
|
expect(edges[0].target).toBe(pve2.id)
|
||||||
|
expect(edges[0].type).toBe('ethernet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clusterL creates an edge from referenced node to this node', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
clusterL:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: cluster
|
||||||
|
linkLabel: ""
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const pve1 = nodes.find((n) => n.data.label === 'PVE1')!
|
||||||
|
const pve2 = nodes.find((n) => n.data.label === 'PVE2')!
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
expect(edges[0].source).toBe(pve1.id)
|
||||||
|
expect(edges[0].target).toBe(pve2.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links array creates multiple edges from this node', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: switch
|
||||||
|
label: "Switch"
|
||||||
|
links:
|
||||||
|
- label: "Server1"
|
||||||
|
linkType: ethernet
|
||||||
|
- label: "Server2"
|
||||||
|
linkType: ethernet
|
||||||
|
- label: "Server3"
|
||||||
|
linkType: wifi
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server1"
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server2"
|
||||||
|
- nodeType: server
|
||||||
|
label: "Server3"
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
const sw = nodes.find((n) => n.data.label === 'Switch')!
|
||||||
|
expect(edges).toHaveLength(3)
|
||||||
|
expect(edges.every((e) => e.source === sw.id)).toBe(true)
|
||||||
|
const targets = edges.map((e) => nodes.find((n) => n.id === e.target)!.data.label)
|
||||||
|
expect(targets).toEqual(expect.arrayContaining(['Server1', 'Server2', 'Server3']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates edges when clusterR on A and clusterL on B point to each other', () => {
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE1"
|
||||||
|
clusterR:
|
||||||
|
label: "PVE2"
|
||||||
|
linkType: ethernet
|
||||||
|
- nodeType: proxmox
|
||||||
|
label: "PVE2"
|
||||||
|
clusterL:
|
||||||
|
label: "PVE1"
|
||||||
|
linkType: ethernet
|
||||||
|
`
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips nodes with same label as existing canvas nodes', () => {
|
||||||
|
const existing: Node<NodeData>[] = [{
|
||||||
|
id: 'existing-1',
|
||||||
|
type: 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'ExistingServer', type: 'server', status: 'online', services: [] },
|
||||||
|
}]
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "ExistingServer"
|
||||||
|
- nodeType: router
|
||||||
|
label: "NewRouter"
|
||||||
|
`
|
||||||
|
const { nodes, imported } = parseYamlToCanvas(yaml, existing, emptyEdges)
|
||||||
|
expect(imported).toBe(1)
|
||||||
|
expect(nodes.filter((n) => n.data.label === 'ExistingServer')).toHaveLength(1)
|
||||||
|
expect(nodes.filter((n) => n.data.label === 'NewRouter')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges with existing edges without duplicating', () => {
|
||||||
|
const existing: Node<NodeData>[] = [
|
||||||
|
{ id: 'a', type: 'server', position: { x: 0, y: 0 }, data: { label: 'A', type: 'server', status: 'online', services: [] } },
|
||||||
|
{ id: 'b', type: 'server', position: { x: 0, y: 0 }, data: { label: 'B', type: 'server', status: 'online', services: [] } },
|
||||||
|
]
|
||||||
|
const existingEdge: Edge<EdgeData>[] = [{
|
||||||
|
id: 'e1', source: 'a', target: 'b', type: 'ethernet',
|
||||||
|
data: { type: 'ethernet' },
|
||||||
|
}]
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: server
|
||||||
|
label: "A"
|
||||||
|
clusterR:
|
||||||
|
label: "B"
|
||||||
|
linkType: ethernet
|
||||||
|
`
|
||||||
|
// A already exists so it's skipped, no new edge created
|
||||||
|
const { edges } = parseYamlToCanvas(yaml, existing, existingEdge)
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on invalid YAML', () => {
|
||||||
|
expect(() => parseYamlToCanvas('{invalid: [yaml', empty, emptyEdges)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when YAML is not an array', () => {
|
||||||
|
const yaml = `nodeType: server\nlabel: oops\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/list/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when nodeType is missing', () => {
|
||||||
|
const yaml = `- label: "Missing type"\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/nodeType/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when label is missing', () => {
|
||||||
|
const yaml = `- nodeType: server\n`
|
||||||
|
expect(() => parseYamlToCanvas(yaml, empty, emptyEdges)).toThrow(/label/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns and skips unknown parent label without crashing', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
const yaml = `
|
||||||
|
- nodeType: vm
|
||||||
|
label: "OrphanVM"
|
||||||
|
parent:
|
||||||
|
label: "NonexistentHost"
|
||||||
|
linkType: virtual
|
||||||
|
`
|
||||||
|
const { nodes, edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(edges).toHaveLength(0)
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NonexistentHost'))
|
||||||
|
warnSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,6 +28,7 @@ describe('ICON_REGISTRY', () => {
|
|||||||
expect(keys).toContain('play') // Jellyfin
|
expect(keys).toContain('play') // Jellyfin
|
||||||
expect(keys).toContain('shield') // Pi-hole
|
expect(keys).toContain('shield') // Pi-hole
|
||||||
expect(keys).toContain('anchor') // Portainer
|
expect(keys).toContain('anchor') // Portainer
|
||||||
|
expect(keys).toContain('package') // Docker Host
|
||||||
expect(keys).toContain('key') // Vaultwarden
|
expect(keys).toContain('key') // Vaultwarden
|
||||||
expect(keys).toContain('database') // DB services
|
expect(keys).toContain('database') // DB services
|
||||||
expect(keys).toContain('cctv') // IP Camera / CCTV
|
expect(keys).toContain('cctv') // IP Camera / CCTV
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
|||||||
|
|
||||||
const NODE_TYPES: NodeType[] = [
|
const NODE_TYPES: NodeType[] = [
|
||||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
'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 EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||||
@@ -84,6 +84,7 @@ describe('THEMES', () => {
|
|||||||
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
expect(d.nodeAccents.server.border).toBe('#a855f7')
|
||||||
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
|
||||||
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
|
||||||
|
expect(d.nodeAccents.docker.border).toBe('#2496ED')
|
||||||
expect(d.nodeCardBackground).toBe('#21262d')
|
expect(d.nodeCardBackground).toBe('#21262d')
|
||||||
expect(d.nodeIconBackground).toBe('#161b22')
|
expect(d.nodeIconBackground).toBe('#161b22')
|
||||||
expect(d.canvasBackground).toBe('#0d1117')
|
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,136 @@
|
|||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData, EdgeType } from '@/types'
|
||||||
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
|
||||||
|
/** Build a map of node id → label for edge resolution */
|
||||||
|
function buildIdToLabel(nodes: Node<NodeData>[]): Map<string, string> {
|
||||||
|
const m = new Map<string, string>()
|
||||||
|
for (const n of nodes) m.set(n.id, n.data.label)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConnection(targetLabel: string, edgeType: EdgeType, edgeLabel: string | undefined): YamlNodeConnection {
|
||||||
|
return {
|
||||||
|
label: targetLabel,
|
||||||
|
linkType: edgeType,
|
||||||
|
linkLabel: edgeLabel ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize React Flow canvas state to a YAML string.
|
||||||
|
* Each node becomes one entry; edges are embedded as parent/clusterR/clusterL sub-objects.
|
||||||
|
* Edge deduplication: each edge is written on exactly one side (source as clusterR, target as clusterL)
|
||||||
|
* unless the edge type is 'virtual' or there is a parentId relationship, in which case
|
||||||
|
* it becomes the 'parent' field of the child node.
|
||||||
|
*/
|
||||||
|
export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData>[]): string {
|
||||||
|
const idToLabel = buildIdToLabel(nodes)
|
||||||
|
|
||||||
|
// Build per-node edge maps (id → connections)
|
||||||
|
// We use a Set to track already-serialized edge ids (deduplication).
|
||||||
|
const serializedEdges = new Set<string>()
|
||||||
|
|
||||||
|
// Index edges by source and target for quick lookup
|
||||||
|
const edgesBySource = new Map<string, Edge<EdgeData>[]>()
|
||||||
|
const edgesByTarget = new Map<string, Edge<EdgeData>[]>()
|
||||||
|
for (const e of edges) {
|
||||||
|
if (!edgesBySource.has(e.source)) edgesBySource.set(e.source, [])
|
||||||
|
edgesBySource.get(e.source)!.push(e)
|
||||||
|
if (!edgesByTarget.has(e.target)) edgesByTarget.set(e.target, [])
|
||||||
|
edgesByTarget.get(e.target)!.push(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlNodes: YamlNode[] = []
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const d = node.data
|
||||||
|
|
||||||
|
// Skip groupRect nodes — they are canvas decoration only
|
||||||
|
if (d.type === 'groupRect') continue
|
||||||
|
|
||||||
|
const entry: YamlNode = {
|
||||||
|
nodeType: d.type,
|
||||||
|
label: d.label,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.custom_icon) entry.nodeIcon = d.custom_icon
|
||||||
|
if (d.hostname) entry.hostname = d.hostname
|
||||||
|
if (d.ip) entry.ipAddress = d.ip
|
||||||
|
if (d.check_method && d.check_method !== 'none') entry.checkMethod = d.check_method
|
||||||
|
if (d.check_target) entry.checkTarget = d.check_target
|
||||||
|
if (d.notes) entry.notes = d.notes
|
||||||
|
|
||||||
|
// Hardware specs — omit zero values
|
||||||
|
if (d.cpu_model) entry.cpuModel = d.cpu_model
|
||||||
|
if (d.cpu_count && d.cpu_count > 0) entry.cpuCore = d.cpu_count
|
||||||
|
if (d.ram_gb && d.ram_gb > 0) entry.ram = d.ram_gb
|
||||||
|
if (d.disk_gb && d.disk_gb > 0) entry.disk = d.disk_gb
|
||||||
|
|
||||||
|
// Parent relationship: if this node has a parentId in React Flow,
|
||||||
|
// encode it as a 'parent' connection using any virtual edge between them.
|
||||||
|
if (node.parentId) {
|
||||||
|
const parentLabel = idToLabel.get(node.parentId) ?? node.parentId
|
||||||
|
// Find an edge between parent and this node (either direction)
|
||||||
|
const parentEdges = [
|
||||||
|
...(edgesBySource.get(node.parentId) ?? []).filter((e) => e.target === node.id),
|
||||||
|
...(edgesByTarget.get(node.parentId) ?? []).filter((e) => e.source === node.id),
|
||||||
|
]
|
||||||
|
const pEdge = parentEdges[0]
|
||||||
|
const linkType: EdgeType = (pEdge?.data?.type as EdgeType) ?? 'virtual'
|
||||||
|
const linkLabel = pEdge?.data?.label ?? ''
|
||||||
|
entry.parent = { label: parentLabel, linkType, linkLabel: linkLabel as string }
|
||||||
|
if (pEdge) serializedEdges.add(pEdge.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing edges (this node is the source):
|
||||||
|
// - cluster type → clusterR (Proxmox cluster link, directional)
|
||||||
|
// - everything else → links array (supports multiple connections)
|
||||||
|
const outgoingEdges = (edgesBySource.get(node.id) ?? []).filter(
|
||||||
|
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId,
|
||||||
|
)
|
||||||
|
for (const e of outgoingEdges) {
|
||||||
|
const targetLabel = idToLabel.get(e.target)
|
||||||
|
if (!targetLabel) continue
|
||||||
|
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
||||||
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
|
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
|
||||||
|
if (edgeType === 'cluster') {
|
||||||
|
if (!entry.clusterR) entry.clusterR = conn
|
||||||
|
} else {
|
||||||
|
entry.links = [...(entry.links ?? []), conn]
|
||||||
|
}
|
||||||
|
serializedEdges.add(e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming cluster edges not yet serialized → clusterL
|
||||||
|
const incomingClusterEdges = (edgesByTarget.get(node.id) ?? []).filter(
|
||||||
|
(e) => !serializedEdges.has(e.id) && (e.data?.type as EdgeType) === 'cluster',
|
||||||
|
)
|
||||||
|
for (const e of incomingClusterEdges) {
|
||||||
|
const sourceLabel = idToLabel.get(e.source)
|
||||||
|
if (!sourceLabel) continue
|
||||||
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
|
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
|
||||||
|
serializedEdges.add(e.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
yamlNodes.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return yaml.dump(yamlNodes, { lineWidth: -1, noRefs: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger a browser file download with the given YAML content */
|
||||||
|
export function downloadYaml(content: string, filename = 'homelable-export.yaml'): void {
|
||||||
|
const blob = new Blob([content], { type: 'text/yaml;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import yaml from 'js-yaml'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
import type { YamlNode, YamlNodeConnection } from '@/types/yaml'
|
||||||
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a YAML string and merge the resulting nodes/edges into the existing canvas.
|
||||||
|
* - Nodes with the same label as an existing node are skipped (no duplicates).
|
||||||
|
* - Positions are computed via dagre auto-layout over the full merged set.
|
||||||
|
*/
|
||||||
|
export function parseYamlToCanvas(
|
||||||
|
yamlString: string,
|
||||||
|
existingNodes: Node<NodeData>[],
|
||||||
|
existingEdges: Edge<EdgeData>[],
|
||||||
|
): { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[]; imported: number } {
|
||||||
|
const raw = yaml.load(yamlString)
|
||||||
|
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
throw new Error('YAML must be a list of node objects (top-level array)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = raw as unknown[]
|
||||||
|
|
||||||
|
// Build lookup: label → existing node id (existing canvas + nodes being added)
|
||||||
|
const labelToId = new Map<string, string>()
|
||||||
|
for (const n of existingNodes) {
|
||||||
|
labelToId.set(n.data.label, n.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: validate and create nodes (without positions — dagre will assign them)
|
||||||
|
const newNodes: Node<NodeData>[] = []
|
||||||
|
const yamlNodes: YamlNode[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const raw = entry as Record<string, unknown>
|
||||||
|
|
||||||
|
if (!raw.nodeType || typeof raw.nodeType !== 'string') {
|
||||||
|
throw new Error(`Each YAML entry must have a "nodeType" string field`)
|
||||||
|
}
|
||||||
|
if (!raw.label || typeof raw.label !== 'string') {
|
||||||
|
throw new Error(`Each YAML entry must have a "label" string field`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const yn = raw as unknown as YamlNode
|
||||||
|
|
||||||
|
// Skip if a node with this label already exists on the canvas
|
||||||
|
if (labelToId.has(yn.label)) {
|
||||||
|
console.warn(`[importYaml] Skipping duplicate label: "${yn.label}"`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = generateUUID()
|
||||||
|
labelToId.set(yn.label, id)
|
||||||
|
|
||||||
|
const hasHardware = !!(yn.cpuModel || yn.cpuCore || yn.ram || yn.disk)
|
||||||
|
|
||||||
|
const data: NodeData = {
|
||||||
|
label: yn.label,
|
||||||
|
type: yn.nodeType,
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
...(yn.hostname ? { hostname: yn.hostname } : {}),
|
||||||
|
...(yn.ipAddress ? { ip: yn.ipAddress } : {}),
|
||||||
|
...(yn.checkMethod ? { check_method: yn.checkMethod } : {}),
|
||||||
|
...(yn.checkTarget ? { check_target: yn.checkTarget } : {}),
|
||||||
|
...(yn.notes ? { notes: yn.notes } : {}),
|
||||||
|
...(yn.nodeIcon ? { custom_icon: yn.nodeIcon } : {}),
|
||||||
|
...(yn.cpuModel ? { cpu_model: yn.cpuModel } : {}),
|
||||||
|
...(yn.cpuCore ? { cpu_count: yn.cpuCore } : {}),
|
||||||
|
...(yn.ram ? { ram_gb: yn.ram } : {}),
|
||||||
|
...(yn.disk ? { disk_gb: yn.disk } : {}),
|
||||||
|
...(hasHardware ? { show_hardware: true } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
newNodes.push({
|
||||||
|
id,
|
||||||
|
type: yn.nodeType,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
yamlNodes.push(yn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: apply parent relationships (parentId / parent_id)
|
||||||
|
const newEdges: Edge<EdgeData>[] = []
|
||||||
|
// Track edge pairs to deduplicate (store as "sourceId|targetId")
|
||||||
|
const edgePairs = new Set<string>(
|
||||||
|
existingEdges.map((e) => `${e.source}|${e.target}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
function addEdgeIfNew(
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string,
|
||||||
|
conn: YamlNodeConnection,
|
||||||
|
) {
|
||||||
|
const key = `${sourceId}|${targetId}`
|
||||||
|
const reverseKey = `${targetId}|${sourceId}`
|
||||||
|
if (edgePairs.has(key) || edgePairs.has(reverseKey)) return
|
||||||
|
edgePairs.add(key)
|
||||||
|
const edgeType = conn.linkType ?? 'ethernet'
|
||||||
|
newEdges.push({
|
||||||
|
id: generateUUID(),
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
type: edgeType,
|
||||||
|
data: {
|
||||||
|
type: edgeType,
|
||||||
|
...(conn.linkLabel ? { label: conn.linkLabel } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < newNodes.length; i++) {
|
||||||
|
const node = newNodes[i]
|
||||||
|
const yn = yamlNodes[i]
|
||||||
|
|
||||||
|
if (yn.parent) {
|
||||||
|
const parentId = labelToId.get(yn.parent.label)
|
||||||
|
if (!parentId) {
|
||||||
|
console.warn(`[importYaml] parent label not found: "${yn.parent.label}" — skipping relationship`)
|
||||||
|
} else {
|
||||||
|
// Set React Flow parentId for nesting
|
||||||
|
node.data = { ...node.data, parent_id: parentId }
|
||||||
|
node.parentId = parentId
|
||||||
|
node.extent = 'parent'
|
||||||
|
// Also create an edge
|
||||||
|
addEdgeIfNew(parentId, node.id, yn.parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.links) {
|
||||||
|
for (const link of yn.links) {
|
||||||
|
const targetId = labelToId.get(link.label)
|
||||||
|
if (!targetId) {
|
||||||
|
console.warn(`[importYaml] links label not found: "${link.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(node.id, targetId, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.clusterR) {
|
||||||
|
const targetId = labelToId.get(yn.clusterR.label)
|
||||||
|
if (!targetId) {
|
||||||
|
console.warn(`[importYaml] clusterR label not found: "${yn.clusterR.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(node.id, targetId, yn.clusterR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (yn.clusterL) {
|
||||||
|
const sourceId = labelToId.get(yn.clusterL.label)
|
||||||
|
if (!sourceId) {
|
||||||
|
console.warn(`[importYaml] clusterL label not found: "${yn.clusterL.label}" — skipping`)
|
||||||
|
} else {
|
||||||
|
addEdgeIfNew(sourceId, node.id, yn.clusterL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and apply layout
|
||||||
|
const mergedNodes = [...existingNodes, ...newNodes]
|
||||||
|
const mergedEdges = [...existingEdges, ...newEdges]
|
||||||
|
const laidOut = applyDagreLayout(mergedNodes, mergedEdges)
|
||||||
|
|
||||||
|
return { nodes: laidOut, edges: mergedEdges, imported: newNodes.length }
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
// Transfers & sync
|
// Transfers & sync
|
||||||
Download, Upload, RefreshCw,
|
Download, Upload, RefreshCw,
|
||||||
// Containers & Dev
|
// Containers & Dev
|
||||||
Anchor, GitBranch, Terminal, Code2, Settings,
|
Anchor, Package, GitBranch, Terminal, Code2, Settings,
|
||||||
// Communications
|
// Communications
|
||||||
Mail, MessageSquare, Phone,
|
Mail, MessageSquare, Phone,
|
||||||
// Misc devices
|
// Misc devices
|
||||||
@@ -98,6 +98,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
|||||||
|
|
||||||
// --- Containers & Dev ---
|
// --- Containers & Dev ---
|
||||||
{ key: 'anchor', label: 'Portainer / Docker', category: 'Dev & Containers', icon: Anchor },
|
{ 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: 'gitbranch', label: 'Gitea / Gitlab', category: 'Dev & Containers', icon: GitBranch },
|
||||||
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
{ key: 'terminal', label: 'SSH / Shell', category: 'Dev & Containers', icon: Terminal },
|
||||||
{ key: 'code', label: 'VS Code Server', category: 'Dev & Containers', icon: Code2 },
|
{ 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' },
|
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||||
generic: { border: '#8b949e', icon: '#8b949e' },
|
generic: { border: '#8b949e', icon: '#8b949e' },
|
||||||
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
|
||||||
},
|
},
|
||||||
@@ -109,6 +110,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||||
|
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||||
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
generic: { border: '#94a3b8', icon: '#94a3b8' },
|
||||||
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
|
||||||
},
|
},
|
||||||
@@ -162,6 +164,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||||
cpl: { border: '#b45309', icon: '#b45309' },
|
cpl: { border: '#b45309', icon: '#b45309' },
|
||||||
|
docker: { border: '#2496ED', icon: '#2496ED' },
|
||||||
generic: { border: '#6b7280', icon: '#6b7280' },
|
generic: { border: '#6b7280', icon: '#6b7280' },
|
||||||
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
groupRect:{ border: '#0284c7', icon: '#0284c7' },
|
||||||
},
|
},
|
||||||
@@ -215,6 +218,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||||
|
docker: { border: '#00aaff', icon: '#00aaff' },
|
||||||
generic: { border: '#8888ff', icon: '#8888ff' },
|
generic: { border: '#8888ff', icon: '#8888ff' },
|
||||||
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
groupRect:{ border: '#00ffff', icon: '#00ffff' },
|
||||||
},
|
},
|
||||||
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
printer: { border: '#005500', icon: '#005500' },
|
printer: { border: '#005500', icon: '#005500' },
|
||||||
computer: { border: '#008822', icon: '#008822' },
|
computer: { border: '#008822', icon: '#008822' },
|
||||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||||
|
docker: { border: '#00cc88', icon: '#00cc88' },
|
||||||
generic: { border: '#006600', icon: '#006600' },
|
generic: { border: '#006600', icon: '#006600' },
|
||||||
groupRect:{ border: '#00ff41', icon: '#00ff41' },
|
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/*"]
|
"@/*": ["./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