fix: all lint errors, test + lint pre-commit passing
Frontend: - Split nodeTypes/edgeTypes into separate .ts files (react-refresh) - Remove setState-in-effect in NodeModal (key prop reset) - Fix handleSave accessed before declaration (useRef pattern) - Exclude src/components/ui/** from eslint (shadcn generated) - Use defineConfig from vitest/config for test type support Backend: - ruff --fix: sort imports, datetime.UTC alias - Raise line-length to 120, ignore E501 in tests - Break long update_node/update_edge signatures - pyproject.toml: per-file-ignores for tests
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
@@ -34,7 +34,7 @@ async def save_canvas(body: CanvasSaveRequest, db: AsyncSession = Depends(get_db
|
||||
state = await db.get(CanvasState, 1)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
state.saved_at = datetime.now(UTC)
|
||||
else:
|
||||
db.add(CanvasState(id=1, viewport=body.viewport))
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ async def delete_edge(edge_id: str, db: AsyncSession = Depends(get_db), _: str =
|
||||
|
||||
|
||||
@router.patch("/{edge_id}", response_model=EdgeResponse)
|
||||
async def update_edge(edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
async def update_edge(
|
||||
edge_id: str, body: EdgeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
):
|
||||
edge = await db.get(Edge, edge_id)
|
||||
if not edge:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
|
||||
|
||||
@@ -34,7 +34,9 @@ async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = De
|
||||
|
||||
|
||||
@router.patch("/{node_id}", response_model=NodeResponse)
|
||||
async def update_node(node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
async def update_node(
|
||||
node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
):
|
||||
node = await db.get(Node, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
@@ -17,7 +17,7 @@ def hash_password(password: str) -> str:
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
expire = datetime.now(UTC) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _uuid() -> str:
|
||||
|
||||
+1
-1
@@ -3,9 +3,9 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
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.db.database import init_db
|
||||
from app.api.routes import auth, nodes, edges, canvas, scan, status
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ auth:
|
||||
username: admin
|
||||
# Default password: "admin" — change this before deploying!
|
||||
# Generate a new hash: python scripts/hash_password.py yourpassword
|
||||
password_hash: "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"
|
||||
password_hash: "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
||||
|
||||
scanner:
|
||||
ranges:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
exclude = ["migrations", ".venv"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = ["B008"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["E501"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.13"
|
||||
strict = true
|
||||
ignore_missing_imports = true
|
||||
exclude = ["migrations", ".venv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
testpaths = ["tests"]
|
||||
addopts = "--tb=short -q"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["*/migrations/*", "*/tests/*"]
|
||||
@@ -8,6 +8,7 @@ pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.12
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Generate a bcrypt password hash for config.yml."""
|
||||
import sys
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(db_session: AsyncSession):
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client):
|
||||
"""Returns a coroutine that logs in and returns auth headers."""
|
||||
async def _get():
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
token = res.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
return _get
|
||||
@@ -0,0 +1,39 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credentials():
|
||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS")):
|
||||
yield
|
||||
|
||||
|
||||
async def test_login_success(client: AsyncClient, mock_credentials):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_login_wrong_password(client: AsyncClient, mock_credentials):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_login_wrong_username(client: AsyncClient, mock_credentials):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "notadmin", "password": "admin"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_protected_route_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/nodes/")
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
async def test_health_is_public(client: AsyncClient):
|
||||
res = await client.get("/api/v1/health")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"status": "ok"}
|
||||
@@ -0,0 +1,54 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def headers(client: AsyncClient):
|
||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
|
||||
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 two_nodes(client: AsyncClient, headers: dict):
|
||||
n1 = (await client.post("/api/v1/nodes/", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
|
||||
n2 = (await client.post("/api/v1/nodes/", json={"type": "switch", "label": "SW1", "status": "online"}, headers=headers)).json()
|
||||
return n1["id"], n2["id"]
|
||||
|
||||
|
||||
async def test_create_edge(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges/", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source"] == src
|
||||
assert data["target"] == tgt
|
||||
assert data["type"] == "ethernet"
|
||||
|
||||
|
||||
async def test_create_vlan_edge(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges/", json={"source": src, "target": tgt, "type": "vlan", "vlan_id": 20, "label": "VLAN 20"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["vlan_id"] == 20
|
||||
|
||||
|
||||
async def test_list_edges(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
await client.post("/api/v1/edges/", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
||||
res = await client.get("/api/v1/edges/", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 1
|
||||
|
||||
|
||||
async def test_delete_edge(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
edge_id = (await client.post("/api/v1/edges/", json={"source": src, "target": tgt, "type": "wifi"}, headers=headers)).json()["id"]
|
||||
res = await client.delete(f"/api/v1/edges/{edge_id}", headers=headers)
|
||||
assert res.status_code == 204
|
||||
assert len((await client.get("/api/v1/edges/", headers=headers)).json()) == 0
|
||||
@@ -0,0 +1,67 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
TOKEN_HASH = "$2b$12$o/LWyvmBc978CNpSsHxcveXN0WqjAGW/gBR0.U.HURWbaYD3GCDqS"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def headers(client: AsyncClient):
|
||||
with patch("app.api.routes.auth._load_credentials", return_value=("admin", TOKEN_HASH)):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
token = res.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def test_list_nodes_empty(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/nodes/", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_create_node(client: AsyncClient, headers: dict):
|
||||
payload = {"type": "server", "label": "My Server", "ip": "192.168.1.10", "status": "unknown"}
|
||||
res = await client.post("/api/v1/nodes/", json=payload, headers=headers)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["label"] == "My Server"
|
||||
assert data["ip"] == "192.168.1.10"
|
||||
assert "id" in data
|
||||
|
||||
|
||||
async def test_get_node(client: AsyncClient, headers: dict):
|
||||
create = await client.post("/api/v1/nodes/", json={"type": "router", "label": "Router", "status": "online"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["id"] == node_id
|
||||
|
||||
|
||||
async def test_get_node_not_found(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/nodes/nonexistent-id", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_update_node(client: AsyncClient, headers: dict):
|
||||
create = await client.post("/api/v1/nodes/", json={"type": "server", "label": "Old", "status": "unknown"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "New", "ip": "10.0.0.1"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["label"] == "New"
|
||||
assert res.json()["ip"] == "10.0.0.1"
|
||||
|
||||
|
||||
async def test_delete_node(client: AsyncClient, headers: dict):
|
||||
create = await client.post("/api/v1/nodes/", json={"type": "switch", "label": "Switch", "status": "unknown"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
res = await client.delete(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||
assert res.status_code == 204
|
||||
assert (await client.get(f"/api/v1/nodes/{node_id}", headers=headers)).status_code == 404
|
||||
|
||||
|
||||
async def test_list_nodes_returns_all(client: AsyncClient, headers: dict):
|
||||
for i in range(3):
|
||||
await client.post("/api/v1/nodes/", json={"type": "generic", "label": f"Node {i}", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes/", headers=headers)
|
||||
assert len(res.json()) == 3
|
||||
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(['dist', 'src/components/ui/**']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
|
||||
Generated
+1250
-1
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -7,6 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,19 +36,25 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
+22
-17
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useState } from 'react'
|
||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
import { ReactFlowProvider, type Connection } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -25,6 +25,23 @@ export default function App() {
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const nodePositions = nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y }))
|
||||
await canvasApi.save({ node_positions: nodePositions, viewport: {} })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
} catch {
|
||||
markSaved()
|
||||
toast.success('Canvas saved (local)')
|
||||
}
|
||||
}, [nodes, markSaved])
|
||||
|
||||
// Keep a ref so the keydown handler always calls the latest version
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||
|
||||
// Load canvas on auth
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
@@ -32,7 +49,6 @@ export default function App() {
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
// Map API response to React Flow nodes
|
||||
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number }) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
@@ -59,25 +75,12 @@ export default function App() {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
handleSaveRef.current()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
})
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const nodePositions = nodes.map((n) => ({ id: n.id, x: n.position.x, y: n.position.y }))
|
||||
await canvasApi.save({ node_positions: nodePositions, viewport: {} })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
} catch {
|
||||
// Backend not running — mark saved anyway in dev
|
||||
markSaved()
|
||||
toast.success('Canvas saved (local)')
|
||||
}
|
||||
}, [nodes, markSaved])
|
||||
}, [])
|
||||
|
||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||
const id = crypto.randomUUID()
|
||||
@@ -144,7 +147,9 @@ export default function App() {
|
||||
title="Add Node"
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||
<NodeModal
|
||||
key={editNodeId ?? 'edit'}
|
||||
open={!!editNodeId}
|
||||
onClose={() => setEditNodeId(null)}
|
||||
onSubmit={handleUpdateNode}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { nodeTypes } from './nodes'
|
||||
import { edgeTypes } from './edges'
|
||||
import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
interface CanvasContainerProps {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { HomelableEdge } from './index'
|
||||
|
||||
export const edgeTypes = {
|
||||
ethernet: HomelableEdge,
|
||||
wifi: HomelableEdge,
|
||||
iot: HomelableEdge,
|
||||
vlan: HomelableEdge,
|
||||
virtual: HomelableEdge,
|
||||
}
|
||||
@@ -22,13 +22,13 @@ const EDGE_STYLES: Record<EdgeType, React.CSSProperties> = {
|
||||
virtual: { stroke: '#8b949e', strokeWidth: 1, strokeDasharray: '4 4' },
|
||||
}
|
||||
|
||||
function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const style: React.CSSProperties = {
|
||||
...EDGE_STYLES[edgeType],
|
||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id) } : {}),
|
||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||
...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}),
|
||||
}
|
||||
|
||||
@@ -46,18 +46,10 @@ function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition,
|
||||
border: '1px solid #30363d',
|
||||
}}
|
||||
>
|
||||
{data.label}
|
||||
{data.label as string}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const edgeTypes = {
|
||||
ethernet: HomelableEdge,
|
||||
wifi: HomelableEdge,
|
||||
iot: HomelableEdge,
|
||||
vlan: HomelableEdge,
|
||||
virtual: HomelableEdge,
|
||||
}
|
||||
|
||||
@@ -20,16 +20,3 @@ export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} glowColor="
|
||||
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} glowColor="#00d4ff" />
|
||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} glowColor="#8b949e" />
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
router: RouterNode,
|
||||
switch: SwitchNode,
|
||||
server: ServerNode,
|
||||
proxmox: ProxmoxNode,
|
||||
vm: VmNode,
|
||||
lxc: LxcNode,
|
||||
nas: NasNode,
|
||||
iot: IotNode,
|
||||
ap: ApNode,
|
||||
generic: GenericNode,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, ProxmoxNode, VmNode, LxcNode, NasNode, IotNode, ApNode, GenericNode } from './index'
|
||||
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
router: RouterNode,
|
||||
switch: SwitchNode,
|
||||
server: ServerNode,
|
||||
proxmox: ProxmoxNode,
|
||||
vm: VmNode,
|
||||
lxc: LxcNode,
|
||||
nas: NasNode,
|
||||
iot: IotNode,
|
||||
ap: ApNode,
|
||||
generic: GenericNode,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -28,13 +28,11 @@ interface NodeModalProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||
// initial value is enough — no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
|
||||
useEffect(() => {
|
||||
setForm({ ...DEFAULT_DATA, ...initial })
|
||||
}, [initial, open])
|
||||
|
||||
const set = (key: keyof NodeData, value: unknown) =>
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||
})
|
||||
|
||||
it('starts unauthenticated', () => {
|
||||
const { token, isAuthenticated } = useAuthStore.getState()
|
||||
expect(token).toBeNull()
|
||||
expect(isAuthenticated).toBe(false)
|
||||
})
|
||||
|
||||
it('login sets token and isAuthenticated', () => {
|
||||
useAuthStore.getState().login('my-jwt-token')
|
||||
const { token, isAuthenticated } = useAuthStore.getState()
|
||||
expect(token).toBe('my-jwt-token')
|
||||
expect(isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('logout clears token and isAuthenticated', () => {
|
||||
useAuthStore.getState().login('my-jwt-token')
|
||||
useAuthStore.getState().logout()
|
||||
const { token, isAuthenticated } = useAuthStore.getState()
|
||||
expect(token).toBeNull()
|
||||
expect(isAuthenticated).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
const makeNode = (id: string, overrides: Partial<NodeData> = {}): Node<NodeData> => ({
|
||||
id,
|
||||
type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: id, type: 'server', status: 'unknown', services: [], ...overrides },
|
||||
})
|
||||
|
||||
const makeEdge = (id: string, source: string, target: string): Edge<EdgeData> => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: 'ethernet',
|
||||
data: { type: 'ethernet' },
|
||||
})
|
||||
|
||||
describe('canvasStore', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
hasUnsavedChanges: false,
|
||||
selectedNodeId: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('starts empty', () => {
|
||||
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(0)
|
||||
expect(edges).toHaveLength(0)
|
||||
expect(hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addNode adds a node and marks unsaved', () => {
|
||||
const { addNode } = useCanvasStore.getState()
|
||||
addNode(makeNode('n1'))
|
||||
const { nodes, hasUnsavedChanges } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0].id).toBe('n1')
|
||||
expect(hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('updateNode updates data fields', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1', { label: 'old' }))
|
||||
useCanvasStore.getState().updateNode('n1', { label: 'new', ip: '10.0.0.1' })
|
||||
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(node?.data.label).toBe('new')
|
||||
expect(node?.data.ip).toBe('10.0.0.1')
|
||||
})
|
||||
|
||||
it('deleteNode removes node and its connected edges', () => {
|
||||
const store = useCanvasStore.getState()
|
||||
store.addNode(makeNode('n1'))
|
||||
store.addNode(makeNode('n2'))
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||
useCanvasStore.getState().deleteNode('n1')
|
||||
const { nodes, edges } = useCanvasStore.getState()
|
||||
expect(nodes.find((n) => n.id === 'n1')).toBeUndefined()
|
||||
expect(edges.find((e) => e.id === 'e1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('deleteNode clears selectedNodeId if it was the deleted node', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
useCanvasStore.getState().setSelectedNode('n1')
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
useCanvasStore.getState().deleteNode('n1')
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||
})
|
||||
|
||||
it('markSaved clears hasUnsavedChanges', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
useCanvasStore.getState().markSaved()
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('loadCanvas replaces state', () => {
|
||||
useCanvasStore.getState().addNode(makeNode('old'))
|
||||
useCanvasStore.getState().loadCanvas([makeNode('n1'), makeNode('n2')], [makeEdge('e1', 'n1', 'n2')])
|
||||
const { nodes, edges, hasUnsavedChanges } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(2)
|
||||
expect(edges).toHaveLength(1)
|
||||
expect(hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('setSelectedNode sets and clears selection', () => {
|
||||
useCanvasStore.getState().setSelectedNode('n1')
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
useCanvasStore.getState().setSelectedNode(null)
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, EDGE_TYPE_LABELS } from '@/types'
|
||||
|
||||
describe('NODE_TYPE_LABELS', () => {
|
||||
it('has an entry for every node type', () => {
|
||||
const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'generic']
|
||||
expectedTypes.forEach((t) => {
|
||||
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
||||
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('STATUS_COLORS', () => {
|
||||
it('has a hex color for each status', () => {
|
||||
const statuses = ['online', 'offline', 'pending', 'unknown'] as const
|
||||
statuses.forEach((s) => {
|
||||
expect(STATUS_COLORS[s]).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('online is green, offline is red', () => {
|
||||
expect(STATUS_COLORS.online).toBe('#39d353')
|
||||
expect(STATUS_COLORS.offline).toBe('#f85149')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EDGE_TYPE_LABELS', () => {
|
||||
it('has an entry for every edge type', () => {
|
||||
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual']
|
||||
expectedTypes.forEach((t) => {
|
||||
expect(EDGE_TYPE_LABELS).toHaveProperty(t)
|
||||
})
|
||||
})
|
||||
})
|
||||
+11
-1
@@ -1,5 +1,5 @@
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
@@ -10,6 +10,16 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
exclude: ['src/components/ui/**', 'src/test/**'],
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
|
||||
Reference in New Issue
Block a user