Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b5317a0c | |||
| 0f643477f6 | |||
| 861d2822b9 | |||
| 059bb3daa7 | |||
| c01d87381d | |||
| ec0519d2b7 | |||
| 1182dbd82d | |||
| 821e324111 | |||
| ea3adc0f94 | |||
| 6f8f0d5e8f | |||
| daf3f59590 | |||
| 212eb37e34 | |||
| 61b8a210fe | |||
| a43ffb813e | |||
| f469d6c744 | |||
| e7ab9a1d7a | |||
| d5b67a770c |
+2
-2
@@ -2,8 +2,8 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install nmap for network scanning
|
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
# Homelable — Installation
|
||||||
|
|
||||||
|
## Quick Start — Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
||||||
|
cd homelable && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open **http://localhost:3000** — login with `admin` / `admin`.
|
||||||
|
|
||||||
|
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
||||||
|
>
|
||||||
|
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
|
||||||
|
>
|
||||||
|
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
|
||||||
|
|
||||||
|
## Quick Start — Frontend only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
|
||||||
|
cd homelable && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update (Docker)
|
||||||
|
|
||||||
|
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
||||||
|
cd homelable && docker compose pull && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Pouzor/homelable.git
|
||||||
|
cd homelable
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proxmox LXC Install
|
||||||
|
|
||||||
|
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
||||||
|
|
||||||
|
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
||||||
|
> ```bash
|
||||||
|
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### Update (LXC)
|
||||||
|
|
||||||
|
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/homelable/scripts/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly from GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is done via `.env` (copied from `.env.example`):
|
||||||
|
|
||||||
|
```env
|
||||||
|
SECRET_KEY=change_me_in_production
|
||||||
|
|
||||||
|
# Auth — default: admin / admin
|
||||||
|
AUTH_USERNAME=admin
|
||||||
|
AUTH_PASSWORD_HASH='$2b$12$...' # bcrypt hash — keep single quotes
|
||||||
|
|
||||||
|
# CIDR ranges to scan
|
||||||
|
SCANNER_RANGES=["192.168.1.0/24"]
|
||||||
|
|
||||||
|
# How often to check node status (seconds)
|
||||||
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
```
|
||||||
|
|
||||||
|
All settings are also editable in-app via the **Scan Network** button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
**Backend (Python 3.13):**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3.13 -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp ../.env.example .env # edit SECRET_KEY and review defaults
|
||||||
|
uvicorn app.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
@@ -16,109 +16,15 @@ If you just like the design, you can only run the frontend and export your desig
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
|
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
|
||||||
|
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Installation
|
||||||
|
|
||||||
```bash
|
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
|
||||||
cd homelable && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Open **http://localhost:3000** — login with `admin` / `admin`.
|
|
||||||
|
|
||||||
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
|
||||||
>
|
|
||||||
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
|
|
||||||
>
|
|
||||||
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
|
|
||||||
|
|
||||||
## Quick Start - Front only
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash -s -- --standalone
|
|
||||||
cd homelable && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
Re-run the install script — it detects an existing install and only updates `docker-compose.yml`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh | bash
|
|
||||||
cd homelable && docker compose pull && docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build from source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Pouzor/homelable.git
|
|
||||||
cd homelable
|
|
||||||
cp .env.example .env
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxmox LXC Install
|
|
||||||
|
|
||||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
|
||||||
|
|
||||||
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
|
||||||
> ```bash
|
|
||||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
|
||||||
> ```
|
|
||||||
|
|
||||||
### Update
|
|
||||||
|
|
||||||
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash /opt/homelable/scripts/update.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Or directly from GitHub:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,7 +34,8 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
|
|||||||
|
|
||||||
### Triggering a scan
|
### Triggering a scan
|
||||||
|
|
||||||
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes. Errors are shown inline and as a toast notification.
|
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
||||||
|
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
||||||
|
|
||||||
### macOS / root privileges
|
### macOS / root privileges
|
||||||
|
|
||||||
@@ -151,19 +58,10 @@ Results are written directly to the database and appear as Pending Devices in th
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Proxmox Nested Nodes
|
|
||||||
|
|
||||||
Proxmox nodes render as a resizable group container. VM and LXC nodes can be placed inside:
|
|
||||||
|
|
||||||
1. Add a **Proxmox VE** node to the canvas
|
|
||||||
2. Add a **VM** or **LXC** node — select the Proxmox node in the **Parent Proxmox** dropdown
|
|
||||||
3. The child node appears inside the group and moves with it
|
|
||||||
4. Select the Proxmox node to reveal resize handles (drag corners to expand)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Node Check Methods
|
## Node Check Methods
|
||||||
|
|
||||||
|
Homelable continuously monitors your nodes and displays their live status (online / offline / unknown) directly on the canvas. Each node can be configured with an independent check method suited to the service it runs.
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `ping` | ICMP ping |
|
| `ping` | ICMP ping |
|
||||||
@@ -176,9 +74,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
|
||||||
|
|
||||||
@@ -262,25 +160,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
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
[
|
||||||
|
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
|
||||||
|
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
|
||||||
|
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
|
||||||
|
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
|
||||||
|
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
||||||
|
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
||||||
|
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
|
||||||
|
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
|
||||||
|
|
||||||
|
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
||||||
|
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
|
||||||
|
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
|
||||||
|
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
|
||||||
|
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
||||||
|
]
|
||||||
+3
-3
@@ -22,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.0.0",
|
version="1.3.3",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,8 +30,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Authorization", "Content-Type"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
"""Match nmap scan results against service_signatures.json."""
|
"""Match nmap scan results against service_signatures.json."""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SIGNATURES: list[dict[str, Any]] | None = None
|
_SIGNATURES: list[dict[str, Any]] | None = None
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _load() -> list[dict[str, Any]]:
|
def _load() -> list[dict[str, Any]]:
|
||||||
global _SIGNATURES
|
global _SIGNATURES
|
||||||
if _SIGNATURES is None:
|
if _SIGNATURES is None:
|
||||||
path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json"
|
with _LOCK:
|
||||||
with open(path) as f:
|
if _SIGNATURES is None:
|
||||||
_SIGNATURES = json.load(f)
|
path = Path(__file__).parent.parent / "data" / "service_signatures.json"
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
_SIGNATURES = json.load(f)
|
||||||
|
except FileNotFoundError as err:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"service_signatures.json not found at {path}. "
|
||||||
|
"This file should be bundled with the application."
|
||||||
|
) from err
|
||||||
return _SIGNATURES
|
return _SIGNATURES
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 614 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
Generated
+149
-113
@@ -42,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",
|
||||||
@@ -1484,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",
|
||||||
@@ -1534,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",
|
||||||
@@ -1547,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",
|
||||||
@@ -2840,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",
|
||||||
@@ -3253,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",
|
||||||
@@ -3347,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",
|
||||||
@@ -3820,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",
|
||||||
@@ -3873,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": {
|
||||||
@@ -5025,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==",
|
||||||
@@ -5037,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",
|
||||||
@@ -5055,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",
|
||||||
@@ -6925,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"
|
||||||
@@ -6989,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": {
|
||||||
@@ -7501,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"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -48,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",
|
||||||
|
|||||||
+15
-1
@@ -6,6 +6,7 @@ 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 { 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'
|
||||||
@@ -33,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()
|
||||||
@@ -379,6 +380,18 @@ export default function App() {
|
|||||||
toast.success('Canvas exported as YAML')
|
toast.success('Canvas exported as YAML')
|
||||||
}, [nodes, edges])
|
}, [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 }
|
||||||
@@ -458,6 +471,7 @@ export default function App() {
|
|||||||
onShortcuts={() => setShortcutsOpen(true)}
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
onExportMd={handleExportMd}
|
onExportMd={handleExportMd}
|
||||||
onExportYaml={handleExportYaml}
|
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">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown } 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'
|
||||||
@@ -13,10 +14,24 @@ interface ToolbarProps {
|
|||||||
onShortcuts: () => void
|
onShortcuts: () => void
|
||||||
onExportMd: () => void
|
onExportMd: () => void
|
||||||
onExportYaml: () => void
|
onExportYaml: () => void
|
||||||
|
onImportYaml: (content: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml }: 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">
|
||||||
@@ -47,15 +62,25 @@ 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>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
|
|
||||||
<FileDown size={14} /> YAML
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { EdgeType, NodeType } from '@/types'
|
import type { NodeType, EdgeType, CheckMethod } from '@/types'
|
||||||
|
|
||||||
export interface YamlNodeConnection {
|
export interface YamlNodeConnection {
|
||||||
label: string
|
label: string
|
||||||
linkType: EdgeType
|
linkType?: EdgeType
|
||||||
linkLabel: string
|
linkLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YamlNode {
|
export interface YamlNode {
|
||||||
@@ -12,9 +12,10 @@ export interface YamlNode {
|
|||||||
label: string
|
label: string
|
||||||
hostname?: string
|
hostname?: string
|
||||||
ipAddress?: string
|
ipAddress?: string
|
||||||
checkMethod?: string
|
checkMethod?: CheckMethod
|
||||||
checkTarget?: string
|
checkTarget?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
|
links?: YamlNodeConnection[]
|
||||||
parent?: YamlNodeConnection
|
parent?: YamlNodeConnection
|
||||||
clusterR?: YamlNodeConnection
|
clusterR?: YamlNodeConnection
|
||||||
clusterL?: YamlNodeConnection
|
clusterL?: YamlNodeConnection
|
||||||
|
|||||||
@@ -68,26 +68,71 @@ describe('exportCanvasToYaml', () => {
|
|||||||
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
|
expect(childEntry.parent).toEqual({ label: 'Proxmox1', linkType: 'virtual', linkLabel: '' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serializes clusterR edge on source node', () => {
|
it('serializes cluster-type edge as clusterR on source node', () => {
|
||||||
const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a')
|
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
|
||||||
const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b')
|
const nodeB = makeNode({ label: 'PVE2', type: 'proxmox' }, 'b')
|
||||||
const edge = makeEdge('e1', 'a', 'b', { type: 'ethernet', label: '10GbE' })
|
const edge = makeEdge('e1', 'a', 'b', { type: 'cluster', label: '10GbE' })
|
||||||
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
const entryA = result.find((e) => e.label === 'NodeA')!
|
const entryA = result.find((e) => e.label === 'PVE1')!
|
||||||
expect(entryA.clusterR).toEqual({ label: 'NodeB', linkType: 'ethernet', linkLabel: '10GbE' })
|
expect(entryA.clusterR).toEqual({ label: 'PVE2', linkType: 'cluster', linkLabel: '10GbE' })
|
||||||
|
expect(entryA).not.toHaveProperty('links')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not duplicate an edge as both clusterR and clusterL', () => {
|
it('serializes cluster-type incoming edge as clusterL on target node', () => {
|
||||||
const nodeA = makeNode({ label: 'NodeA', type: 'proxmox' }, 'a')
|
const nodeA = makeNode({ label: 'PVE1', type: 'proxmox' }, 'a')
|
||||||
const nodeB = makeNode({ label: 'NodeB', type: 'proxmox' }, 'b')
|
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 edge = makeEdge('e1', 'a', 'b', { type: 'ethernet' })
|
||||||
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||||
const entryA = result.find((e) => e.label === 'NodeA')!
|
const entryA = result.find((e) => e.label === 'NodeA')!
|
||||||
const entryB = result.find((e) => e.label === 'NodeB')!
|
const entryB = result.find((e) => e.label === 'NodeB')!
|
||||||
// clusterR on A and clusterL on B would duplicate — only one side should have it
|
expect(entryA.links).toHaveLength(1)
|
||||||
const hasClusterR = 'clusterR' in entryA
|
expect(entryB).not.toHaveProperty('links')
|
||||||
const hasClusterL = 'clusterL' in entryB
|
|
||||||
expect(hasClusterR && hasClusterL).toBe(false)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes groupRect nodes from output', () => {
|
it('excludes groupRect nodes from output', () => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -84,35 +84,35 @@ export function exportCanvasToYaml(nodes: Node<NodeData>[], edges: Edge<EdgeData
|
|||||||
if (pEdge) serializedEdges.add(pEdge.id)
|
if (pEdge) serializedEdges.add(pEdge.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-parent edges: serialize as clusterR (source side) or clusterL (target side).
|
// Outgoing edges (this node is the source):
|
||||||
// We process source edges as clusterR on this node; target edges as clusterL on this node,
|
// - cluster type → clusterR (Proxmox cluster link, directional)
|
||||||
// but only if the edge hasn't been serialized yet (deduplication: source wins).
|
// - everything else → links array (supports multiple connections)
|
||||||
const sourceEdgesForNode = (edgesBySource.get(node.id) ?? []).filter(
|
const outgoingEdges = (edgesBySource.get(node.id) ?? []).filter(
|
||||||
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId && e.source !== node.parentId,
|
(e) => !serializedEdges.has(e.id) && e.target !== node.parentId,
|
||||||
)
|
)
|
||||||
for (const e of sourceEdgesForNode) {
|
for (const e of outgoingEdges) {
|
||||||
const targetLabel = idToLabel.get(e.target)
|
const targetLabel = idToLabel.get(e.target)
|
||||||
if (!targetLabel) continue
|
if (!targetLabel) continue
|
||||||
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
||||||
const edgeLabel = e.data?.label as string | undefined
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
if (!entry.clusterR) {
|
const conn = makeConnection(targetLabel, edgeType, edgeLabel)
|
||||||
entry.clusterR = makeConnection(targetLabel, edgeType, edgeLabel)
|
if (edgeType === 'cluster') {
|
||||||
|
if (!entry.clusterR) entry.clusterR = conn
|
||||||
|
} else {
|
||||||
|
entry.links = [...(entry.links ?? []), conn]
|
||||||
}
|
}
|
||||||
// Only first clusterR wins per node; mark all source edges as serialized
|
|
||||||
serializedEdges.add(e.id)
|
serializedEdges.add(e.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetEdgesForNode = (edgesByTarget.get(node.id) ?? []).filter(
|
// Incoming cluster edges not yet serialized → clusterL
|
||||||
(e) => !serializedEdges.has(e.id) && e.source !== node.parentId && e.target !== node.parentId,
|
const incomingClusterEdges = (edgesByTarget.get(node.id) ?? []).filter(
|
||||||
|
(e) => !serializedEdges.has(e.id) && (e.data?.type as EdgeType) === 'cluster',
|
||||||
)
|
)
|
||||||
for (const e of targetEdgesForNode) {
|
for (const e of incomingClusterEdges) {
|
||||||
const sourceLabel = idToLabel.get(e.source)
|
const sourceLabel = idToLabel.get(e.source)
|
||||||
if (!sourceLabel) continue
|
if (!sourceLabel) continue
|
||||||
const edgeType: EdgeType = (e.data?.type as EdgeType) ?? 'ethernet'
|
|
||||||
const edgeLabel = e.data?.label as string | undefined
|
const edgeLabel = e.data?.label as string | undefined
|
||||||
if (!entry.clusterL) {
|
if (!entry.clusterL) entry.clusterL = makeConnection(sourceLabel, 'cluster', edgeLabel)
|
||||||
entry.clusterL = makeConnection(sourceLabel, edgeType, edgeLabel)
|
|
||||||
}
|
|
||||||
serializedEdges.add(e.id)
|
serializedEdges.add(e.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user