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:
Pouzor
2026-03-06 23:58:10 +01:00
parent 150302c3f7
commit 44a448e26d
30 changed files with 1731 additions and 63 deletions
+1 -1
View File
@@ -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: [
+1250 -1
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -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
View File
@@ -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,
}
+3 -11
View File
@@ -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,
}
+3 -5
View File
@@ -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()
})
})
+1
View File
@@ -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
View File
@@ -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',