diff --git a/Dockerfile.backend b/Dockerfile.backend new file mode 100644 index 0000000..1238a94 --- /dev/null +++ b/Dockerfile.backend @@ -0,0 +1,18 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install nmap for network scanning +RUN apt-get update && apt-get install -y --no-install-recommends nmap && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . + +# Create data directory (volume mount point) +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend new file mode 100644 index 0000000..ed5ddd5 --- /dev/null +++ b/Dockerfile.frontend @@ -0,0 +1,19 @@ +# Stage 1: build +FROM node:20-alpine AS builder + +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ . +RUN npm run build + +# Stage 2: serve +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +# Nginx config: proxy /api and /ws to backend, serve SPA +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4f6e7c --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# Homelable + +A self-hosted, open-source tool to visually map, document and monitor your homelab infrastructure. + +Interactive network canvas where each node is a physical machine, VM, LXC container, switch, or device. Nodes show live status, IPs, hostnames, and running services. Edges represent network links. + +--- + +## Features + +- **Interactive canvas** — drag, zoom, pan, snap-to-grid (React Flow) +- **11 node types** — ISP, router, switch, server, Proxmox, VM, LXC, NAS, IoT, AP, generic +- **5 edge types** — ethernet, Wi-Fi, IoT, VLAN (color-coded), virtual +- **Live status** — per-node checks via ping / HTTP / HTTPS / SSH / TCP / Prometheus +- **Network scanner** — nmap-based discovery, approve/hide/ignore new devices +- **Auto-layout** — one-click Dagre hierarchical arrangement +- **Export** — download canvas as PNG +- **Dark theme** — neon accent colors, JetBrains Mono for technical values +- **Self-contained** — SQLite database, single config file, no cloud dependency + +--- + +## Quick Start — Docker + +```bash +git clone https://github.com/you/homelable.git +cd homelable + +docker compose up -d +``` + +Open **http://localhost:3000** — login with `admin` / `admin`. + +> Change the password before exposing to a network: edit `backend/config.yml` and replace `password_hash` with a new bcrypt hash. +> +> Generate a hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"` + +--- + +## Quick Start — Development + +**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 +uvicorn app.main:app --reload --port 8000 +``` + +**Frontend:** +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +Default login: `admin` / `admin` + +--- + +## Proxmox LXC Install + +Run inside a Debian/Ubuntu LXC container: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/you/homelable/main/scripts/lxc-install.sh) +``` + +This installs the backend as a systemd service and serves the frontend via nginx. + +--- + +## Configuration + +`backend/config.yml`: + +```yaml +auth: + username: admin + password_hash: "$2b$12$..." # bcrypt hash + +scanner: + ranges: + - "192.168.1.0/24" # CIDR ranges to scan + +status_checker: + interval_seconds: 60 # how often to check node status +``` + +All settings are also editable in-app via the **Scan Network** button. + +--- + +## Node Check Methods + +| Method | Description | +|--------|-------------| +| `ping` | ICMP ping | +| `http` | GET request, success if status < 500 | +| `https` | GET with TLS verify | +| `tcp` | TCP connect (target: `host:port`) | +| `ssh` | TCP connect to port 22 | +| `prometheus` | GET `/metrics` | +| `health` | GET `/health` | + +--- + +## Stack + +| Layer | Tech | +|-------|------| +| Frontend | React 18, TypeScript, Vite, React Flow v12, Zustand, Tailwind CSS, Shadcn/ui | +| Backend | FastAPI, SQLAlchemy async, SQLite, APScheduler, python-nmap | +| Auth | JWT (python-jose), bcrypt (passlib) | +| Deployment | Docker Compose, nginx, systemd | + +--- + +## Development + +```bash +# Backend tests +cd backend && source .venv/bin/activate +pytest # 40 tests + +# Backend lint +ruff check . + +# Frontend tests +cd frontend && npm test + +# Frontend lint + typecheck +npm run lint && npm run typecheck +``` diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 0df8030..aa6a4ba 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -1,22 +1,54 @@ -from fastapi import APIRouter, Depends +import logging + +import yaml +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user -from app.db.database import get_db -from app.db.models import PendingDevice, ScanRun +from app.core.config import settings +from app.db.database import AsyncSessionLocal, get_db +from app.db.models import Node, PendingDevice, ScanRun +from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse +from app.services.scanner import run_scan + +class ScanConfig(BaseModel): + ranges: list[str] + interval_seconds: int + +logger = logging.getLogger(__name__) router = APIRouter() +def _load_ranges() -> list[str]: + try: + with open(settings.config_path) as f: + cfg = yaml.safe_load(f) + return cfg.get("scanner", {}).get("ranges", []) + except Exception: + return [] + + +async def _background_scan(run_id: str, ranges: list[str]) -> None: + async with AsyncSessionLocal() as db: + await run_scan(ranges, db, run_id) + + @router.post("/trigger", response_model=ScanRunResponse) -async def trigger_scan(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): - run = ScanRun(status="running", ranges=[]) +async def trigger_scan( + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +): + ranges = _load_ranges() + run = ScanRun(status="running", ranges=ranges) db.add(run) await db.commit() await db.refresh(run) - # TODO: launch scanner in background thread + background_tasks.add_task(_background_scan, run.id, ranges) return run @@ -26,13 +58,27 @@ async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_ return result.scalars().all() -@router.post("/pending/{device_id}/approve") -async def approve_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): +@router.get("/hidden", response_model=list[PendingDeviceResponse]) +async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden")) + return result.scalars().all() + + +@router.post("/pending/{device_id}/approve", response_model=dict) +async def approve_device( + device_id: str, + node_data: NodeCreate, + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +): device = await db.get(PendingDevice, device_id) if device: device.status = "approved" + node = Node(**node_data.model_dump()) + db.add(node) await db.commit() - return {"approved": True} + return {"approved": True, "node_id": node.id} + return {"approved": False} @router.post("/pending/{device_id}/hide") @@ -42,3 +88,44 @@ async def hide_device(device_id: str, db: AsyncSession = Depends(get_db), _: str device.status = "hidden" await db.commit() return {"hidden": True} + + +@router.post("/pending/{device_id}/ignore") +async def ignore_device(device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + device = await db.get(PendingDevice, device_id) + if device: + await db.delete(device) + await db.commit() + return {"ignored": True} + + +@router.get("/runs", response_model=list[ScanRunResponse]) +async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)): + result = await db.execute(select(ScanRun).order_by(ScanRun.started_at.desc()).limit(20)) + return result.scalars().all() + + +@router.get("/config", response_model=ScanConfig) +async def get_scan_config(_: str = Depends(get_current_user)): + try: + with open(settings.config_path) as f: + cfg = yaml.safe_load(f) + ranges = cfg.get("scanner", {}).get("ranges", []) + interval = int(cfg.get("status_checker", {}).get("interval_seconds", 60)) + return ScanConfig(ranges=ranges, interval_seconds=interval) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.post("/config", response_model=ScanConfig) +async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)): + try: + with open(settings.config_path) as f: + cfg = yaml.safe_load(f) or {} + cfg.setdefault("scanner", {})["ranges"] = payload.ranges + cfg.setdefault("status_checker", {})["interval_seconds"] = payload.interval_seconds + with open(settings.config_path, "w") as f: + yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True) + return payload + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py new file mode 100644 index 0000000..abd9fe9 --- /dev/null +++ b/backend/app/core/scheduler.py @@ -0,0 +1,66 @@ +"""APScheduler setup for background scan and status check jobs.""" +import logging +from datetime import UTC, datetime + +import yaml +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from sqlalchemy import select + +from app.core.config import settings +from app.db.database import AsyncSessionLocal +from app.db.models import Node +from app.services.status_checker import check_node + +logger = logging.getLogger(__name__) + +scheduler = AsyncIOScheduler() + + +async def _run_status_checks() -> None: + """Check all nodes and broadcast results via WebSocket.""" + from app.api.routes.status import broadcast_status # avoid circular import + + async with AsyncSessionLocal() as db: + result = await db.execute(select(Node)) + nodes = result.scalars().all() + + for node in nodes: + if not node.check_method: + continue + try: + result = await check_node(node.check_method, node.check_target, node.ip) + async with AsyncSessionLocal() as db: + n = await db.get(Node, node.id) + if n: + n.status = result["status"] + n.response_time_ms = result["response_time_ms"] + n.last_seen = datetime.now(UTC) if result["status"] == "online" else n.last_seen + await db.commit() + await broadcast_status( + node_id=node.id, + status=result["status"], + checked_at=datetime.now(UTC).isoformat(), + response_time_ms=result["response_time_ms"], + ) + except Exception as exc: + logger.error("Status check failed for node %s: %s", node.id, exc) + + +def _load_interval() -> int: + try: + with open(settings.config_path) as f: + cfg = yaml.safe_load(f) + return int(cfg.get("status_checker", {}).get("interval_seconds", 60)) + except Exception: + return 60 + + +def start_scheduler() -> None: + interval = _load_interval() + scheduler.add_job(_run_status_checks, "interval", seconds=interval, id="status_checks") + scheduler.start() + logger.info("Scheduler started — status checks every %ds", interval) + + +def stop_scheduler() -> None: + scheduler.shutdown(wait=False) diff --git a/backend/app/main.py b/backend/app/main.py index 47722e7..f66a36e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,13 +5,16 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.routes import auth, canvas, edges, nodes, scan, status from app.core.config import settings +from app.core.scheduler import start_scheduler, stop_scheduler from app.db.database import init_db @asynccontextmanager async def lifespan(app: FastAPI): await init_db() + start_scheduler() yield + stop_scheduler() app = FastAPI( diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py new file mode 100644 index 0000000..fa85c5f --- /dev/null +++ b/backend/app/services/fingerprint.py @@ -0,0 +1,67 @@ +"""Match nmap scan results against service_signatures.json.""" +import json +import re +from pathlib import Path + +_SIGNATURES: list[dict] | None = None + + +def _load() -> list[dict]: + global _SIGNATURES + if _SIGNATURES is None: + path = Path(__file__).parent.parent.parent / "data" / "service_signatures.json" + with open(path) as f: + _SIGNATURES = json.load(f) + return _SIGNATURES + + +def match_port(port: int, protocol: str, banner: str | None = None) -> dict | None: + """Return the first signature matching port+protocol, optionally banner.""" + for sig in _load(): + if sig["port"] != port or sig["protocol"] != protocol: + continue + if sig.get("banner_regex") and banner and not re.search(sig["banner_regex"], banner, re.IGNORECASE): + continue + return sig + return None + + +def fingerprint_ports(open_ports: list[dict]) -> list[dict]: + """ + Given a list of {port, protocol, banner?} dicts, return matched services. + Unknown ports are included as unknown_service. + """ + results = [] + for p in open_ports: + sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner")) + if sig: + results.append({ + "port": p["port"], + "protocol": p.get("protocol", "tcp"), + "service_name": sig["service_name"], + "icon": sig.get("icon"), + "category": sig.get("category"), + }) + else: + results.append({ + "port": p["port"], + "protocol": p.get("protocol", "tcp"), + "service_name": "unknown_service", + "icon": None, + "category": None, + }) + return results + + +def suggest_node_type(open_ports: list[dict]) -> str: + """Suggest a node type based on the most specific matched signature.""" + priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "iot", "switch"] + found: set[str] = set() + for p in open_ports: + sig = match_port(p["port"], p.get("protocol", "tcp")) + if sig and sig.get("suggested_node_type"): + found.add(sig["suggested_node_type"]) + for t in priority: + if t in found: + return t + return "generic" diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py new file mode 100644 index 0000000..7ab4f16 --- /dev/null +++ b/backend/app/services/scanner.py @@ -0,0 +1,138 @@ +"""Network scanner: ARP sweep + nmap service detection.""" +import logging +import socket +from datetime import UTC, datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import PendingDevice, ScanRun +from app.services.fingerprint import fingerprint_ports, suggest_node_type + +logger = logging.getLogger(__name__) + +try: + import nmap # type: ignore[import-untyped] + _NMAP_AVAILABLE = True +except ImportError: + _NMAP_AVAILABLE = False + logger.warning("python-nmap not available — scanner will run in mock mode") + + +def _nmap_scan(target: str) -> list[dict]: + """Run nmap -sV --open on target, return list of host dicts.""" + if not _NMAP_AVAILABLE: + return _mock_scan(target) + + nm = nmap.PortScanner() + try: + nm.scan(hosts=target, arguments="-sV --open -T4 --host-timeout 30s") + except Exception as exc: + logger.error("nmap scan failed: %s", exc) + return [] + + hosts = [] + for host in nm.all_hosts(): + if nm[host].state() != "up": + continue + open_ports = [] + for proto in nm[host].all_protocols(): + for port, info in nm[host][proto].items(): + if info["state"] == "open": + open_ports.append({ + "port": port, + "protocol": proto, + "banner": info.get("product", "") + " " + info.get("version", ""), + }) + hosts.append({ + "ip": host, + "hostname": _resolve_hostname(host), + "mac": nm[host].get("addresses", {}).get("mac"), + "os": _extract_os(nm, host), + "open_ports": open_ports, + }) + return hosts + + +def _resolve_hostname(ip: str) -> str | None: + try: + return socket.gethostbyaddr(ip)[0] + except Exception: + return None + + +def _extract_os(nm: object, host: str) -> str | None: + try: + osmatch = nm[host].get("osmatch", []) # type: ignore[index] + if osmatch: + return osmatch[0]["name"] + except Exception: + pass + return None + + +def _mock_scan(target: str) -> list[dict]: + """Return fake results for dev/test environments without nmap.""" + return [ + { + "ip": "192.168.1.99", + "hostname": "unknown-device.lan", + "mac": "AA:BB:CC:DD:EE:FF", + "os": None, + "open_ports": [ + {"port": 80, "protocol": "tcp", "banner": "nginx"}, + {"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.0"}, + ], + } + ] + + +async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: + """Execute scan for given CIDR ranges and populate pending_devices.""" + devices_found = 0 + try: + for cidr in ranges: + hosts = _nmap_scan(cidr) + for host in hosts: + services = fingerprint_ports(host["open_ports"]) + suggested_type = suggest_node_type(host["open_ports"]) + + # Skip if already pending or already a node (by IP) + existing = await db.execute( + __import__("sqlalchemy", fromlist=["select"]).select(PendingDevice).where( + PendingDevice.ip == host["ip"], + PendingDevice.status == "pending", + ) + ) + if existing.scalar_one_or_none(): + continue + + device = PendingDevice( + ip=host["ip"], + mac=host.get("mac"), + hostname=host.get("hostname"), + os=host.get("os"), + services=services, + suggested_type=suggested_type, + status="pending", + ) + db.add(device) + devices_found += 1 + + await db.commit() + + # Update scan run + run = await db.get(ScanRun, run_id) + if run: + run.status = "done" + run.devices_found = devices_found + run.finished_at = datetime.now(UTC) + await db.commit() + + except Exception as exc: + logger.error("Scan failed: %s", exc) + run = await db.get(ScanRun, run_id) + if run: + run.status = "error" + run.error = str(exc) + run.finished_at = datetime.now(UTC) + await db.commit() diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py new file mode 100644 index 0000000..4aa1d67 --- /dev/null +++ b/backend/app/services/status_checker.py @@ -0,0 +1,80 @@ +"""Per-node status checks: ping, http, https, tcp, ssh, prometheus, health.""" +import asyncio +import logging +import socket +import time + +import httpx + +logger = logging.getLogger(__name__) + + +async def check_node(check_method: str, target: str | None, ip: str | None) -> dict: + """ + Run the appropriate check and return {status, response_time_ms}. + status is one of: online, offline, unknown. + """ + host = target or ip + if not host: + return {"status": "unknown", "response_time_ms": None} + + start = time.monotonic() + try: + match check_method: + case "ping": + ok = await _ping(host) + case "http": + url = host if host.startswith("http") else f"http://{host}" + ok = await _http_get(url) + case "https": + url = host if host.startswith("https") else f"https://{host}" + ok = await _http_get(url, verify=True) + case "tcp": + host_part, _, port_str = host.rpartition(":") + port = int(port_str) if port_str.isdigit() else 80 + ok = await _tcp_connect(host_part or host, port) + case "ssh": + ok = await _tcp_connect(host, 22) + case "prometheus": + url = host if host.startswith("http") else f"http://{host}/metrics" + ok = await _http_get(url) + case "health": + url = host if host.startswith("http") else f"http://{host}/health" + ok = await _http_get(url) + case _: + ok = await _ping(host) + + elapsed_ms = int((time.monotonic() - start) * 1000) + return {"status": "online" if ok else "offline", "response_time_ms": elapsed_ms} + + except Exception as exc: + logger.debug("Check failed for %s (%s): %s", host, check_method, exc) + return {"status": "offline", "response_time_ms": None} + + +async def _ping(host: str) -> bool: + proc = await asyncio.create_subprocess_exec( + "ping", "-c", "1", "-W", "1", host, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await proc.wait() + return proc.returncode == 0 + + +async def _http_get(url: str, verify: bool = False) -> bool: + async with httpx.AsyncClient(verify=verify, timeout=5) as client: + resp = await client.get(url, follow_redirects=True) + return resp.status_code < 500 + + +async def _tcp_connect(host: str, port: int) -> bool: + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=3 + ) + writer.close() + await writer.wait_closed() + return True + except (TimeoutError, OSError, socket.gaierror): + return False diff --git a/backend/data/service_signatures.json b/backend/data/service_signatures.json new file mode 100644 index 0000000..b0866ce --- /dev/null +++ b/backend/data/service_signatures.json @@ -0,0 +1,37 @@ +[ + {"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "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": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"}, + {"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, + {"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, + {"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"}, + {"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "server"}, + {"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"}, + {"port": 3000, "protocol": "tcp", "banner_regex": null, "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": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, + {"port": 5000, "protocol": "tcp", "banner_regex": null, "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": 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", "icon": "share-2", "category": "file-sharing", "suggested_node_type": "nas"}, + {"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "file-sharing", "suggested_node_type": "nas"}, + {"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "file-sharing", "suggested_node_type": "server"}, + {"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL", "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": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"}, + {"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter", "icon": "code", "category": "dev", "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": 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"}, + {"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"}, + {"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix", "icon": "message-square", "category": "messaging", "suggested_node_type": "server"}, + {"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"}, + {"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "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"} +] diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py new file mode 100644 index 0000000..9b9dc9b --- /dev/null +++ b/backend/tests/test_scan.py @@ -0,0 +1,161 @@ +"""Tests for scan routes: trigger, pending devices, approve/hide/ignore.""" +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import AsyncClient + +from app.db.models import PendingDevice + + +@pytest.fixture +async def headers(client: AsyncClient): + res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}) + token = res.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +async def pending_device(db_session): + import uuid + device = PendingDevice( + id=str(uuid.uuid4()), + ip="192.168.1.100", + mac="aa:bb:cc:dd:ee:ff", + hostname="my-server", + os="Linux", + services=[{"port": 22, "name": "ssh"}], + suggested_type="server", + status="pending", + ) + db_session.add(device) + await db_session.commit() + await db_session.refresh(device) + return device + + +# --- Trigger scan --- + +@pytest.mark.asyncio +async def test_trigger_scan_requires_auth(client: AsyncClient): + res = await client.post("/api/v1/scan/trigger") + # FastAPI's OAuth2PasswordBearer returns 403 when no token is provided + assert res.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_trigger_scan_creates_run(client: AsyncClient, headers): + with ( + patch("app.api.routes.scan._background_scan", new_callable=AsyncMock), + patch("app.api.routes.scan._load_ranges", return_value=["192.168.1.0/24"]), + ): + res = await client.post("/api/v1/scan/trigger", headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["status"] == "running" + assert data["ranges"] == ["192.168.1.0/24"] + assert "id" in data + + +# --- Pending devices --- + +@pytest.mark.asyncio +async def test_list_pending_empty(client: AsyncClient, headers): + res = await client.get("/api/v1/scan/pending", headers=headers) + assert res.status_code == 200 + assert res.json() == [] + + +@pytest.mark.asyncio +async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device): + res = await client.get("/api/v1/scan/pending", headers=headers) + assert res.status_code == 200 + data = res.json() + assert len(data) == 1 + assert data[0]["ip"] == "192.168.1.100" + assert data[0]["hostname"] == "my-server" + + +# --- Approve device --- + +@pytest.mark.asyncio +async def test_approve_device(client: AsyncClient, headers, pending_device): + node_payload = { + "label": "My Server", + "type": "server", + "ip": "192.168.1.100", + "hostname": "my-server", + "status": "unknown", + "services": [], + } + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json=node_payload, + headers=headers, + ) + assert res.status_code == 200 + data = res.json() + assert data["approved"] is True + assert "node_id" in data + + # Device should no longer appear in pending list + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert pending_res.json() == [] + + +@pytest.mark.asyncio +async def test_approve_nonexistent_device(client: AsyncClient, headers): + node_payload = { + "label": "Ghost", + "type": "generic", + "ip": "10.0.0.1", + "status": "unknown", + "services": [], + } + res = await client.post( + "/api/v1/scan/pending/nonexistent-id/approve", + json=node_payload, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["approved"] is False + + +# --- Hide device --- + +@pytest.mark.asyncio +async def test_hide_device(client: AsyncClient, headers, pending_device): + res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) + assert res.status_code == 200 + assert res.json()["hidden"] is True + + # Should no longer appear in pending + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert pending_res.json() == [] + + # Should appear in hidden + hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) + assert len(hidden_res.json()) == 1 + + +# --- Ignore device --- + +@pytest.mark.asyncio +async def test_ignore_device(client: AsyncClient, headers, pending_device): + res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers) + assert res.status_code == 200 + assert res.json()["ignored"] is True + + # Device should be gone from both pending and hidden + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert pending_res.json() == [] + hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) + assert hidden_res.json() == [] + + +# --- Scan runs --- + +@pytest.mark.asyncio +async def test_list_runs_empty(client: AsyncClient, headers): + res = await client.get("/api/v1/scan/runs", headers=headers) + assert res.status_code == 200 + assert res.json() == [] diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py new file mode 100644 index 0000000..da9b3b3 --- /dev/null +++ b/backend/tests/test_status_checker.py @@ -0,0 +1,162 @@ +"""Tests for status_checker service: each check method.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from app.services.status_checker import _tcp_connect, check_node + +# --- check_node dispatcher --- + +@pytest.mark.asyncio +async def test_check_node_unknown_without_host(): + result = await check_node("ping", None, None) + assert result["status"] == "unknown" + assert result["response_time_ms"] is None + + +@pytest.mark.asyncio +async def test_check_node_ping_online(): + with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=True): + result = await check_node("ping", None, "192.168.1.1") + assert result["status"] == "online" + assert result["response_time_ms"] is not None + + +@pytest.mark.asyncio +async def test_check_node_ping_offline(): + with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=False): + result = await check_node("ping", None, "192.168.1.1") + assert result["status"] == "offline" + + +@pytest.mark.asyncio +async def test_check_node_http_online(): + with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True): + result = await check_node("http", "192.168.1.1:8080", None) + assert result["status"] == "online" + + +@pytest.mark.asyncio +async def test_check_node_http_prepends_scheme(): + """If target doesn't start with http, http:// is prepended.""" + captured = {} + + async def fake_http_get(url, verify=False): + captured["url"] = url + return True + + with patch("app.services.status_checker._http_get", side_effect=fake_http_get): + await check_node("http", "192.168.1.1:8080", None) + + assert captured["url"].startswith("http://") + + +@pytest.mark.asyncio +async def test_check_node_https_uses_verify(): + captured = {} + + async def fake_http_get(url, verify=False): + captured["verify"] = verify + return True + + with patch("app.services.status_checker._http_get", side_effect=fake_http_get): + await check_node("https", "https://myserver", None) + + assert captured["verify"] is True + + +@pytest.mark.asyncio +async def test_check_node_ssh(): + with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock, return_value=True) as mock_tcp: + result = await check_node("ssh", None, "192.168.1.5") + mock_tcp.assert_called_once_with("192.168.1.5", 22) + assert result["status"] == "online" + + +@pytest.mark.asyncio +async def test_check_node_tcp_parses_port(): + captured = {} + + async def fake_tcp(host, port): + captured["host"] = host + captured["port"] = port + return True + + with patch("app.services.status_checker._tcp_connect", side_effect=fake_tcp): + await check_node("tcp", "192.168.1.10:9090", None) + + assert captured["host"] == "192.168.1.10" + assert captured["port"] == 9090 + + +@pytest.mark.asyncio +async def test_check_node_prometheus_appends_metrics(): + captured = {} + + async def fake_http_get(url, verify=False): + captured["url"] = url + return True + + with patch("app.services.status_checker._http_get", side_effect=fake_http_get): + await check_node("prometheus", "192.168.1.10:9090", None) + + assert "/metrics" in captured["url"] + + +@pytest.mark.asyncio +async def test_check_node_health_appends_health(): + captured = {} + + async def fake_http_get(url, verify=False): + captured["url"] = url + return True + + with patch("app.services.status_checker._http_get", side_effect=fake_http_get): + await check_node("health", "192.168.1.10:8080", None) + + assert "/health" in captured["url"] + + +@pytest.mark.asyncio +async def test_check_node_unknown_method_falls_back_to_ping(): + with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=True) as mock_ping: + result = await check_node("foobar", None, "10.0.0.1") + mock_ping.assert_called_once() + assert result["status"] == "online" + + +@pytest.mark.asyncio +async def test_check_node_exception_returns_offline(): + with patch("app.services.status_checker._ping", new_callable=AsyncMock, side_effect=RuntimeError("boom")): + result = await check_node("ping", None, "10.0.0.1") + assert result["status"] == "offline" + assert result["response_time_ms"] is None + + +# --- _tcp_connect --- + +@pytest.mark.asyncio +async def test_tcp_connect_success(): + writer_mock = MagicMock() + writer_mock.close = MagicMock() + writer_mock.wait_closed = AsyncMock() + with patch("asyncio.open_connection", new_callable=AsyncMock, return_value=(MagicMock(), writer_mock)): + result = await _tcp_connect("192.168.1.1", 22) + assert result is True + + +@pytest.mark.asyncio +async def test_tcp_connect_timeout(): + async def timeout_open(*args, **kwargs): + raise TimeoutError() + + with patch("asyncio.open_connection", side_effect=timeout_open): + result = await _tcp_connect("192.168.1.1", 22) + assert result is False + + +@pytest.mark.asyncio +async def test_tcp_connect_os_error(): + with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")): + result = await _tcp_connect("192.168.1.1", 9999) + assert result is False diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cf7573 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + backend: + build: + context: . + dockerfile: Dockerfile.backend + restart: unless-stopped + environment: + SECRET_KEY: ${SECRET_KEY:-change_me_in_production} + SQLITE_PATH: /app/data/homelab.db + CONFIG_PATH: /app/config.yml + CORS_ORIGINS: '["http://localhost:3000"]' + volumes: + - backend_data:/app/data + - ./backend/config.yml:/app/config.yml + networks: + - homelable + # Required for ping-based status checks + cap_add: + - NET_RAW + + frontend: + build: + context: . + dockerfile: Dockerfile.frontend + restart: unless-stopped + ports: + - "3000:80" + depends_on: + - backend + networks: + - homelable + +volumes: + backend_data: + +networks: + homelable: + driver: bridge diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..a6b197e --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Proxy API to backend + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # Proxy WebSocket + location /ws/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da11949..54244b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@base-ui/react": "^1.2.0", + "@dagrejs/dagre": "^2.0.4", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", @@ -799,6 +800,21 @@ "node": ">=20.19.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "3.0.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.54.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0e61f2e..9c9bf0a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@dagrejs/dagre": "^2.0.4", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59d2668..6241aef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ import { useEffect, useCallback, useRef, useState } from 'react' import { ReactFlowProvider, type Connection } from '@xyflow/react' import { type Node } from '@xyflow/react' +import { applyDagreLayout } from '@/utils/layout' +import { exportToPng } from '@/utils/export' import { TooltipProvider } from '@/components/ui/tooltip' import { Toaster } from '@/components/ui/sonner' import { toast } from 'sonner' @@ -11,19 +13,25 @@ import { DetailPanel } from '@/components/panels/DetailPanel' import { LoginPage } from '@/components/LoginPage' import { NodeModal } from '@/components/modals/NodeModal' import { EdgeModal } from '@/components/modals/EdgeModal' +import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' import { canvasApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' +import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData } from '@/types' export default function App() { - const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes } = useCanvasStore() + const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, nodes, edges } = useCanvasStore() + const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() + useStatusPolling() + const [addNodeOpen, setAddNodeOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) + const [scanConfigOpen, setScanConfigOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope const handleSave = useCallback(async () => { @@ -104,6 +112,23 @@ export default function App() { setEditNodeId(null) }, [editNodeId, updateNode]) + const handleAutoLayout = useCallback(() => { + const laid = applyDagreLayout(nodes, edges) + loadCanvas(laid, edges) + toast.success('Canvas auto-arranged') + }, [nodes, edges, loadCanvas]) + + const handleExport = useCallback(async () => { + const el = canvasRef.current?.querySelector('.react-flow') + if (!el) { toast.error('Canvas not ready'); return } + try { + await exportToPng(el) + toast.success('Exported as PNG') + } catch { + toast.error('Export failed') + } + }, []) + const handleEdgeConnect = useCallback((connection: Connection) => { setPendingConnection(connection) }, []) @@ -124,17 +149,19 @@ export default function App() {
setAddNodeOpen(true)} - onScan={() => toast.info('Network scan not yet implemented')} + onScan={() => setScanConfigOpen(true)} onSave={handleSave} />
toast.info('Auto-layout not yet implemented')} - onExport={() => toast.info('Export not yet implemented')} + onAutoLayout={handleAutoLayout} + onExport={handleExport} />
- +
+ +
{selectedNodeId && }
@@ -163,6 +190,12 @@ export default function App() { onSubmit={handleEdgeConfirm} /> + setScanConfigOpen(false)} + onScanNow={() => toast.success('Scan triggered')} + /> + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ef5a876..90defed 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -40,3 +40,15 @@ export const edgesApi = { create: (data: object) => api.post('/edges', data), delete: (id: string) => api.delete(`/edges/${id}`), } + +export const scanApi = { + trigger: () => api.post('/scan/trigger'), + pending: () => api.get('/scan/pending'), + hidden: () => api.get('/scan/hidden'), + runs: () => api.get('/scan/runs'), + approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData), + hide: (id: string) => api.post(`/scan/pending/${id}/hide`), + ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), + getConfig: () => api.get<{ ranges: string[]; interval_seconds: number }>('/scan/config'), + saveConfig: (data: { ranges: string[]; interval_seconds: number }) => api.post('/scan/config', data), +} diff --git a/frontend/src/components/modals/ScanConfigModal.tsx b/frontend/src/components/modals/ScanConfigModal.tsx new file mode 100644 index 0000000..4fe101d --- /dev/null +++ b/frontend/src/components/modals/ScanConfigModal.tsx @@ -0,0 +1,126 @@ +import { useState, useEffect } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { scanApi } from '@/api/client' +import { toast } from 'sonner' + +interface ScanConfigModalProps { + open: boolean + onClose: () => void + onScanNow: () => void +} + +export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) { + const [ranges, setRanges] = useState(['']) + const [interval, setInterval] = useState(60) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!open) return + scanApi.getConfig() + .then((res) => { + setRanges(res.data.ranges.length > 0 ? res.data.ranges : ['']) + setInterval(res.data.interval_seconds) + }) + .catch(() => {/* use defaults */}) + }, [open]) + + const handleSave = async () => { + const cleaned = ranges.map((r) => r.trim()).filter(Boolean) + if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } + setSaving(true) + try { + await scanApi.saveConfig({ ranges: cleaned, interval_seconds: interval }) + toast.success('Scan config saved') + onClose() + } catch { + toast.error('Failed to save config') + } finally { + setSaving(false) + } + } + + const handleScanNow = async () => { + const cleaned = ranges.map((r) => r.trim()).filter(Boolean) + if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } + await handleSave() + onScanNow() + onClose() + } + + return ( + !v && onClose()}> + + + Scan Configuration + + +
+ {/* IP Ranges */} +
+ + {ranges.map((r, i) => ( +
+ { + const next = [...ranges] + next[i] = e.target.value + setRanges(next) + }} + placeholder="192.168.1.0/24" + className="font-mono text-sm bg-[#0d1117] border-border" + /> + +
+ ))} + +
+ + {/* Status check interval */} +
+ + setInterval(Number(e.target.value))} + className="font-mono text-sm bg-[#0d1117] border-border w-32" + /> +
+
+ + + + + + +
+
+ ) +} diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 038cf8e..45c6150 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,7 +1,9 @@ -import { useState } from 'react' -import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff } from 'lucide-react' +import { useState, useCallback } from 'react' +import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Check, EyeOff as Hide, Trash2, RefreshCw, Loader2 } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' +import { scanApi } from '@/api/client' +import { toast } from 'sonner' type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' @@ -12,6 +14,28 @@ const VIEWS = [ { id: 'history' as SidebarView, icon: Clock, label: 'Scan History' }, ] +interface PendingDevice { + id: string + ip: string + mac: string | null + hostname: string | null + os: string | null + services: unknown[] + suggested_type: string | null + status: string + discovered_at: string +} + +interface ScanRun { + id: string + status: string + ranges: string[] + devices_found: number + started_at: string + finished_at: string | null + error: string | null +} + interface SidebarProps { onAddNode: () => void onScan: () => void @@ -26,6 +50,16 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) { const onlineCount = nodes.filter((n) => n.data.status === 'online').length const offlineCount = nodes.filter((n) => n.data.status === 'offline').length + const handleScan = useCallback(async () => { + try { + await scanApi.trigger() + toast.success('Network scan started') + onScan() + } catch { + toast.error('Failed to trigger scan') + } + }, [onScan]) + return (