Compare commits

...

22 Commits

Author SHA1 Message Date
Pouzor ec0519d2b7 chore: bump version to 1.3.1 2026-03-27 11:26:26 +01:00
Pouzor 1182dbd82d Update first screenshot 2026-03-27 11:25:21 +01:00
Pouzor 821e324111 fix: downgrade eslint to ^9.x — eslint-plugin-react-hooks incompatible with eslint 10 2026-03-27 09:26:54 +01:00
Pouzor ea3adc0f94 update dependency 2026-03-27 09:21:46 +01:00
Pouzor 6f8f0d5e8f docs: split README — move installation to INSTALLATION.md, add network scanner intro, remove proxmox nested nodes section 2026-03-27 09:19:22 +01:00
Pouzor daf3f59590 ui: swap icons between Export and PNG buttons 2026-03-24 01:35:39 +01:00
Pouzor 212eb37e34 ui: place Export next to Import, swap PNG/Export icons 2026-03-24 01:34:13 +01:00
Pouzor 61b8a210fe ui: rename toolbar buttons — YAML export to Export, PNG export to PNG 2026-03-24 01:31:22 +01:00
Pouzor a43ffb813e fix: export all edges via links array — clusterR/clusterL reserved for cluster-type edges only 2026-03-24 01:25:26 +01:00
Remy f469d6c744 Merge pull request #4 from Pouzor/feat/yaml-import
feat: YAML import
2026-03-24 01:18:56 +01:00
Pouzor e7ab9a1d7a feat: add YAML import — merge nodes/edges from .yaml file into canvas 2026-03-24 01:14:54 +01:00
Remy d5b67a770c Merge pull request #3 from Pouzor/feat/yaml-export
feat: YAML export
2026-03-24 01:12:05 +01:00
Pouzor 2e49c14028 feat: add YAML export — serialize canvas nodes/edges to downloadable .yaml file 2026-03-23 21:59:04 +01:00
Pouzor d9787fdcbb security issue 2026-03-21 01:09:06 +01:00
Pouzor 06ec18a137 feat: add hardware specs to nodes (CPU, RAM, Disk) with canvas display
- Add cpu_count, cpu_model, ram_gb, disk_gb, show_hardware fields to NodeData
- NodeModal: collapsible Hardware section with toggle to show specs on canvas
- BaseNode: 2-line hardware section with Cpu/MemoryStick/HardDrive icons, only renders set fields
- DetailPanel: Hardware section with GB/TB formatting
- Backend: schema, model, DB migration, canvas save/load support
- Tests: frontend (NodeModal, DetailPanel) + backend (canvas persist/load)
2026-03-21 00:01:15 +01:00
Pouzor adb4474687 feat: add Docker node type, group rect border style, and categorized node type selector
- Fix DockerNode default icon: use Anchor instead of Package
- Add border_style option to Group Rectangle (solid/dashed/dotted/double/none)
- Group node type selector by category (Hardware, Virtualization, IoT, Generic)
- Add tests for border style feature
2026-03-20 21:09:58 +01:00
Pouzor 2008f9467a fix: exclude test files from tsconfig.app.json to fix production build 2026-03-18 00:56:33 +01:00
Pouzor d9ac9462a8 feat: add update script for LXC installs and document update procedure 2026-03-18 00:52:43 +01:00
Pouzor e14a9e87aa fix: stop exposing JWT in WebSocket URL query param
Token was visible in server logs, browser history, and proxy access logs.
Backend now accepts the connection first, then validates a JSON auth
message {"token": "<jwt>"} sent by the client on open before adding
the socket to the active connections pool.
2026-03-18 00:49:03 +01:00
Pouzor e5d7260696 fix: resolve WebSocket failure and crypto.randomUUID crash on HTTP/LXC
- Replace crypto.randomUUID() with a polyfill (generateUUID) that falls
  back to crypto.getRandomValues or Math.random — fixes crash on HTTP
  non-secure contexts where randomUUID is unavailable
- Fix WebSocket URL hardcoding port 8000 — use window.location.host so
  connections go through Nginx proxy in Docker/LXC instead of bypassing it
- Add /api/v1/status/ws/ location block in nginx.conf with WebSocket
  upgrade headers (must precede /api/ to avoid missing Upgrade header)
2026-03-18 00:16:42 +01:00
Pouzor df3b7a8cb0 fix: show validation error when label is empty in NodeModal
Silent return when label was empty made the Add button appear broken.
Native browser required tooltip doesn't render in Radix Dialog portals.
Now shows inline red error message and highlights the field.
2026-03-16 01:01:47 +01:00
Remy 426af29180 Merge pull request #2 from Pouzor/feat/mcp-server
feat: MCP server for AI integration
2026-03-16 00:53:56 +01:00
42 changed files with 1989 additions and 306 deletions
+120
View File
@@ -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
```
+8 -118
View File
@@ -21,90 +21,9 @@ If you just like the design, you can only run the frontend and export your desig
--- ---
## 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 +33,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 +57,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,9 +73,9 @@ Proxmox nodes render as a resizable group container. VM and LXC nodes can be pla
--- ---
## MCP Server (AI Integration) ## MCP Server (AI Integration) (optionnal)
Homelable exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it. 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.
### What the AI can do ### What the AI can do
@@ -248,25 +159,4 @@ Or add it manually to `~/.claude.json`:
- Rotate the key any time by updating `MCP_API_KEY` in `.env` and restarting: `docker compose restart mcp`. - 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. - The MCP server communicates with the backend over the internal Docker network — the backend API is never directly exposed to MCP clients.
---
## Development Mode
**Backend (Python 3.13):**
```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 -4
View File
@@ -11,11 +11,23 @@ _connections: list[WebSocket] = []
@router.websocket("/ws/status") @router.websocket("/ws/status")
async def ws_status(websocket: WebSocket, token: str | None = None) -> None: async def ws_status(websocket: WebSocket) -> None:
if not token or not decode_token(token): # Accept first so we can send a close frame with a reason code
await websocket.close(code=1008) # Policy Violation
return
await websocket.accept() await websocket.accept()
try:
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
_connections.append(websocket) _connections.append(websocket)
try: try:
while True: while True:
+10
View File
@@ -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]:
+5
View File
@@ -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)
+1 -1
View File
@@ -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.1",
lifespan=lifespan, lifespan=lifespan,
) )
+5
View File
@@ -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
+10
View File
@@ -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):
@@ -46,6 +51,11 @@ class NodeUpdate(BaseModel):
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):
+53
View File
@@ -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
+19 -10
View File
@@ -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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+11 -1
View File
@@ -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

+160 -116
View File
@@ -13,12 +13,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",
@@ -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.4.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -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"
+4 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.3.1",
"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
View File
@@ -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,36 +53,77 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
/> />
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Icon */} {/* Main row */}
<div <div className="flex flex-row items-center gap-2.5 px-2.5 py-2">
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0" {/* Icon */}
style={{ <div
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor, className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
background: theme.colors.nodeIconBackground, style={{
}} color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
> background: theme.colors.nodeIconBackground,
{createElement(resolvedIcon, { size: 15 })} }}
>
{createElement(resolvedIcon, { size: 15 })}
</div>
{/* Label + IP */}
<div className="flex flex-col min-w-0">
<div
className="text-xs font-medium leading-tight truncate max-w-[110px]"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label}
</div>
{data.ip && (
<div
className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
</div>
)}
</div>
</div> </div>
{/* Details */} {/* Hardware section */}
<div className="flex flex-col min-w-0"> {showHardware && (
<div <>
className="text-xs font-medium leading-tight truncate max-w-[110px]" <div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
style={{ color: theme.colors.nodeLabelColor }} <div className="flex flex-col gap-1 px-2.5 py-1.5">
title={data.label} {/* Line 1: CPU */}
> {(data.cpu_model || data.cpu_count != null) && (
{data.label} <div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
</div> <Cpu size={9} className="shrink-0" />
{data.ip && ( {data.cpu_model && (
<div <span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
className="font-mono text-[10px] truncate" )}
style={{ color: theme.colors.nodeSubtextColor }} {data.cpu_count != null && (
title={data.ip} <span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
> )}
{hideIp ? maskIp(data.ip) : data.ip} </div>
)}
{/* Line 2: RAM + Disk */}
{(data.ram_gb != null || data.disk_gb != null) && (
<div className="flex items-center gap-2 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
{data.ram_gb != null && (
<span className="flex items-center gap-0.5">
<MemoryStick size={9} className="shrink-0" />
{formatStorage(data.ram_gb)}
</span>
)}
{data.disk_gb != null && (
<span className="flex items-center gap-0.5">
<HardDrive size={9} className="shrink-0" />
{formatStorage(data.disk_gb)}
</span>
)}
</div>
)}
</div> </div>
)} </>
</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>
+114 -10
View File
@@ -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,10 +83,20 @@ 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]" />}
</SelectItem> <SelectGroup key={group.label}>
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type} value={type} className="text-sm pl-4">
{NODE_TYPE_LABELS[type]}
</SelectItem>
))}
</SelectGroup>
</>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -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">
+32 -3
View File
@@ -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()
})
})
})
+7 -2
View File
@@ -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)
+2 -1
View File
@@ -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,
+8
View File
@@ -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',
} }
+26
View File
@@ -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
+2 -1
View File
@@ -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')
+26
View File
@@ -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)
})
})
+136
View File
@@ -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)
}
+170
View File
@@ -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 }
}
+2 -1
View File
@@ -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 },
+5
View File
@@ -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' },
}, },
+25
View File
@@ -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)
})
}
+2 -1
View File
@@ -28,5 +28,6 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/__tests__/**", "src/test/**"]
} }
+66
View File
@@ -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 ""