feat: Phase 3 & 4 — monitoring, discovery, polish, deployment
Phase 3 — Discovery & Monitoring: - Network scanner: nmap wrapper + mock fallback, fingerprint service (35 signatures) - Status checker: ping/http/https/tcp/ssh/prometheus/health per-node checks - APScheduler: status checks every 60s, WebSocket broadcast - WebSocket /ws/status: live node status updates to frontend - Sidebar panels: Pending Devices, Hidden Devices, Scan History - Auth token persisted to localStorage (survive page refresh) - 24 new backend tests (scan flow + status_checker) Phase 4 — Polish & Deployment: - Auto-layout: Dagre hierarchical TB via Toolbar button - Export PNG: html-to-image download via Toolbar button - Scan config modal: CIDR ranges + check interval, GET/POST /api/v1/scan/config - Dockerfile.backend (Python 3.13 slim + nmap), Dockerfile.frontend (nginx) - docker-compose.yml with data volume and NET_RAW cap for ping - scripts/lxc-install.sh: Proxmox VE systemd bootstrap - README.md: quick-start, config reference, stack overview
This commit is contained in:
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
]
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+16
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+38
-5
@@ -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<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(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<HTMLElement>('.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() {
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
|
||||
<Sidebar
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onScan={() => toast.info('Network scan not yet implemented')}
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Toolbar
|
||||
onSave={handleSave}
|
||||
onAutoLayout={() => toast.info('Auto-layout not yet implemented')}
|
||||
onExport={() => toast.info('Export not yet implemented')}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<CanvasContainer onConnect={handleEdgeConnect} />
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
<CanvasContainer onConnect={handleEdgeConnect} />
|
||||
</div>
|
||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,6 +190,12 @@ export default function App() {
|
||||
onSubmit={handleEdgeConfirm}
|
||||
/>
|
||||
|
||||
<ScanConfigModal
|
||||
open={scanConfigOpen}
|
||||
onClose={() => setScanConfigOpen(false)}
|
||||
onScanNow={() => toast.success('Scan triggered')}
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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<string[]>([''])
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Scan Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* IP Ranges */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">IP Ranges (CIDR)</Label>
|
||||
{ranges.map((r, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input
|
||||
value={r}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="shrink-0 text-muted-foreground hover:text-[#f85149]"
|
||||
onClick={() => setRanges(ranges.filter((_, j) => j !== i))}
|
||||
disabled={ranges.length === 1}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setRanges([...ranges, ''])}
|
||||
>
|
||||
<Plus size={13} /> Add range
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status check interval */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-muted-foreground">Status check interval (seconds)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={interval}
|
||||
onChange={(e) => setInterval(Number(e.target.value))}
|
||||
className="font-mono text-sm bg-[#0d1117] border-border w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="outline" onClick={handleSave} disabled={saving}>Save</Button>
|
||||
<Button
|
||||
onClick={handleScanNow}
|
||||
disabled={saving}
|
||||
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||
>
|
||||
Scan Now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<aside
|
||||
className="flex flex-col border-r border-border bg-[#161b22] transition-all duration-200 relative shrink-0"
|
||||
@@ -50,7 +84,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Views */}
|
||||
<nav className="flex flex-col gap-0.5 p-2 flex-1">
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
{VIEWS.map(({ id, icon: Icon, label }) => (
|
||||
<SidebarItem
|
||||
key={id}
|
||||
@@ -63,7 +97,21 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Stats */}
|
||||
{/* View content (only when expanded) */}
|
||||
{!collapsed && activeView !== 'canvas' && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||
{activeView === 'pending' && <PendingDevicesPanel />}
|
||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
||||
{activeView === 'history' && <ScanHistoryPanel />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats (only on canvas view) */}
|
||||
{!collapsed && activeView === 'canvas' && (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Stats footer */}
|
||||
{!collapsed && (
|
||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||
<div className="flex justify-between">
|
||||
@@ -84,7 +132,7 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={onScan} />
|
||||
<SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
@@ -98,6 +146,227 @@ export function Sidebar({ onAddNode, onScan, onSave }: SidebarProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingDevicesPanel() {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { addNode } = useCanvasStore()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.pending()
|
||||
setDevices(res.data)
|
||||
} catch {
|
||||
toast.error('Failed to load pending devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load on mount
|
||||
useState(() => { load() })
|
||||
|
||||
const handleApprove = async (device: PendingDevice) => {
|
||||
try {
|
||||
const nodeData = {
|
||||
label: device.hostname ?? device.ip,
|
||||
type: device.suggested_type ?? 'generic',
|
||||
ip: device.ip,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: 'unknown',
|
||||
services: device.services ?? [],
|
||||
}
|
||||
const res = await scanApi.approve(device.id, nodeData)
|
||||
const nodeId = res.data.node_id
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type: nodeData.type,
|
||||
position: { x: 400, y: 300 },
|
||||
data: { ...nodeData, status: 'unknown' as const },
|
||||
})
|
||||
toast.success(`Approved ${nodeData.label}`)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
} catch {
|
||||
toast.error('Failed to approve device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleHide = async (id: string) => {
|
||||
try {
|
||||
await scanApi.hide(id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
||||
toast.success('Device hidden')
|
||||
} catch {
|
||||
toast.error('Failed to hide device')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = async (id: string) => {
|
||||
try {
|
||||
await scanApi.ignore(id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
||||
} catch {
|
||||
toast.error('Failed to ignore device')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && devices.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||
)}
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
||||
<div className="font-mono text-foreground">{d.ip}</div>
|
||||
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
|
||||
{d.os && <div className="text-muted-foreground truncate text-[10px]">{d.os}</div>}
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
<ActionButton icon={Check} label="Approve" color="green" onClick={() => handleApprove(d)} />
|
||||
<ActionButton icon={Hide} label="Hide" onClick={() => handleHide(d.id)} />
|
||||
<ActionButton icon={Trash2} label="Ignore" color="red" onClick={() => handleIgnore(d.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HiddenDevicesPanel() {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.hidden()
|
||||
setDevices(res.data)
|
||||
} catch {
|
||||
toast.error('Failed to load hidden devices')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useState(() => { load() })
|
||||
|
||||
const handleIgnore = async (id: string) => {
|
||||
try {
|
||||
await scanApi.ignore(id)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
||||
} catch {
|
||||
toast.error('Failed to remove device')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && devices.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
|
||||
)}
|
||||
{devices.map((d) => (
|
||||
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
||||
<div className="font-mono text-foreground">{d.ip}</div>
|
||||
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
|
||||
<div className="flex gap-1 mt-1.5">
|
||||
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScanHistoryPanel() {
|
||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.runs()
|
||||
setRuns(res.data)
|
||||
} catch {
|
||||
toast.error('Failed to load scan history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useState(() => { load() })
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === 'completed' ? '#39d353' : s === 'running' ? '#e3b341' : s === 'failed' ? '#f85149' : '#8b949e'
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && runs.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
|
||||
)}
|
||||
{runs.map((r) => (
|
||||
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{new Date(r.started_at).toLocaleString()}
|
||||
</div>
|
||||
{r.ranges.length > 0 && (
|
||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||
)}
|
||||
{r.error && <div className="text-[#f85149] text-[10px] mt-0.5 truncate">{r.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
color?: 'green' | 'red'
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
|
||||
const colorClass =
|
||||
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
|
||||
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
|
||||
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
|
||||
<Icon size={11} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
interface StatusMessage {
|
||||
node_id: string
|
||||
status: 'online' | 'offline' | 'pending' | 'unknown'
|
||||
checked_at: string
|
||||
response_time_ms: number | null
|
||||
}
|
||||
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { updateNode } = useCanvasStore()
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !token) return
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.hostname
|
||||
const url = `${protocol}://${host}:8000/api/v1/status/ws/status`
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: StatusMessage = JSON.parse(event.data)
|
||||
updateNode(msg.node_id, {
|
||||
status: msg.status,
|
||||
response_time_ms: msg.response_time_ms ?? undefined,
|
||||
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
|
||||
})
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
// silently ignore — backend may not be running in dev
|
||||
}
|
||||
|
||||
return () => {
|
||||
ws.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [isAuthenticated, token, updateNode])
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
@@ -7,9 +8,14 @@ interface AuthState {
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
login: (token) => set({ token, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, isAuthenticated: false }),
|
||||
}))
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
login: (token) => set({ token, isAuthenticated: true }),
|
||||
logout: () => set({ token: null, isAuthenticated: false }),
|
||||
}),
|
||||
{ name: 'homelable-auth' }
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { toPng } from 'html-to-image'
|
||||
|
||||
/**
|
||||
* Export the React Flow canvas as a PNG and trigger a browser download.
|
||||
* Pass the `.react-flow` wrapper element.
|
||||
*/
|
||||
export async function exportToPng(element: HTMLElement): Promise<void> {
|
||||
const dataUrl = await toPng(element, {
|
||||
backgroundColor: '#0d1117',
|
||||
style: {
|
||||
// Exclude controls and minimap from the export
|
||||
'--xy-controls-display': 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.download = 'homelable-canvas.png'
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import dagre from '@dagrejs/dagre'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
const NODE_WIDTH = 180
|
||||
const NODE_HEIGHT = 52
|
||||
|
||||
/**
|
||||
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
|
||||
* Returns new nodes with updated positions.
|
||||
*/
|
||||
export function applyDagreLayout(
|
||||
nodes: Node<NodeData>[],
|
||||
edges: Edge<EdgeData>[],
|
||||
): Node<NodeData>[] {
|
||||
const g = new dagre.graphlib.Graph()
|
||||
g.setDefaultEdgeLabel(() => ({}))
|
||||
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
|
||||
|
||||
for (const node of nodes) {
|
||||
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||
}
|
||||
for (const edge of edges) {
|
||||
g.setEdge(edge.source, edge.target)
|
||||
}
|
||||
|
||||
dagre.layout(g)
|
||||
|
||||
return nodes.map((node) => {
|
||||
const pos = g.node(node.id)
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: pos.x - NODE_WIDTH / 2,
|
||||
y: pos.y - NODE_HEIGHT / 2,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Homelable — LXC/VM bootstrap installer
|
||||
# Compatible with Proxmox VE (Debian/Ubuntu LXC containers)
|
||||
# Usage: bash <(curl -fsSL https://raw.githubusercontent.com/you/homelable/main/scripts/lxc-install.sh)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR=/opt/homelable
|
||||
DATA_DIR=/opt/homelable/data
|
||||
SERVICE_USER=homelable
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root (sudo bash ...)"
|
||||
|
||||
# ── Detect OS ────────────────────────────────────────────────────────────────
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
else
|
||||
error "Cannot detect OS"
|
||||
fi
|
||||
|
||||
info "Detected: $PRETTY_NAME"
|
||||
[[ "$OS" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
|
||||
|
||||
# ── System deps ──────────────────────────────────────────────────────────────
|
||||
info "Installing system dependencies..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq \
|
||||
python3 python3-pip python3-venv \
|
||||
nmap curl git nginx
|
||||
|
||||
# ── Node.js (for frontend build) ─────────────────────────────────────────────
|
||||
if ! command -v node &>/dev/null; then
|
||||
info "Installing Node.js 20..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt-get install -y -qq nodejs
|
||||
fi
|
||||
|
||||
# ── Service user ─────────────────────────────────────────────────────────────
|
||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
||||
useradd --system --shell /sbin/nologin "$SERVICE_USER"
|
||||
info "Created user: $SERVICE_USER"
|
||||
fi
|
||||
|
||||
# ── Clone / update repo ───────────────────────────────────────────────────────
|
||||
REPO_URL="https://github.com/you/homelable.git" # ← update before publishing
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
info "Updating existing installation..."
|
||||
git -C "$INSTALL_DIR" pull
|
||||
else
|
||||
info "Cloning repository..."
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||
info "Setting up Python backend..."
|
||||
cd "$INSTALL_DIR/backend"
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install --quiet -r requirements.txt
|
||||
|
||||
# Generate config.yml if missing
|
||||
if [[ ! -f config.yml ]]; then
|
||||
cp config.yml.example config.yml 2>/dev/null || cat > config.yml <<'CONF'
|
||||
auth:
|
||||
username: admin
|
||||
password_hash: "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
||||
|
||||
scanner:
|
||||
ranges:
|
||||
- "192.168.1.0/24"
|
||||
|
||||
status_checker:
|
||||
interval_seconds: 60
|
||||
CONF
|
||||
warn "Created default config.yml — change admin password!"
|
||||
fi
|
||||
|
||||
# Generate .env if missing
|
||||
if [[ ! -f .env ]]; then
|
||||
SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
|
||||
cat > .env <<EOF
|
||||
SECRET_KEY=$SECRET
|
||||
SQLITE_PATH=$DATA_DIR/homelab.db
|
||||
CONFIG_PATH=$INSTALL_DIR/backend/config.yml
|
||||
CORS_ORIGINS=http://localhost
|
||||
EOF
|
||||
fi
|
||||
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$DATA_DIR"
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR/backend/.venv"
|
||||
|
||||
# ── systemd: backend ─────────────────────────────────────────────────────────
|
||||
cat > /etc/systemd/system/homelable-backend.service <<EOF
|
||||
[Unit]
|
||||
Description=Homelable Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$SERVICE_USER
|
||||
WorkingDirectory=$INSTALL_DIR/backend
|
||||
EnvironmentFile=$INSTALL_DIR/backend/.env
|
||||
ExecStart=$INSTALL_DIR/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# ── Frontend ──────────────────────────────────────────────────────────────────
|
||||
info "Building frontend..."
|
||||
cd "$INSTALL_DIR/frontend"
|
||||
npm ci --silent
|
||||
VITE_API_BASE=/api npm run build
|
||||
|
||||
# ── nginx ─────────────────────────────────────────────────────────────────────
|
||||
cp "$INSTALL_DIR/docker/nginx.conf" /etc/nginx/sites-available/homelable
|
||||
# Adjust for local backend (not docker network)
|
||||
sed -i 's/http:\/\/backend:8000/http:\/\/127.0.0.1:8000/g' /etc/nginx/sites-available/homelable
|
||||
# Adjust root to dist
|
||||
sed -i "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" /etc/nginx/sites-available/homelable
|
||||
ln -sf /etc/nginx/sites-available/homelable /etc/nginx/sites-enabled/homelable
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
# ── Enable & start ────────────────────────────────────────────────────────────
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now homelable-backend
|
||||
systemctl enable --now nginx
|
||||
|
||||
info "Done!"
|
||||
echo ""
|
||||
echo -e " ${GREEN}Homelable is running at http://$(hostname -I | awk '{print $1}')${NC}"
|
||||
echo -e " Default login: admin / admin"
|
||||
echo -e " ${YELLOW}Change the password in $INSTALL_DIR/backend/config.yml${NC}"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user