Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e8bab5dec | |||
| 9d9fdd61e9 | |||
| b0df8f389a | |||
| 75c7f25a30 | |||
| 8bd1c48976 | |||
| 05c98355a6 | |||
| 323dea6798 | |||
| 19cb4b71f5 | |||
| fd86c0f6ad | |||
| 00d44abfad | |||
| 3bd18ab543 | |||
| 4d8bb246f1 | |||
| 07da498d18 | |||
| 0e59f15608 | |||
| bd22891fab | |||
| d96b502524 | |||
| 9cb9d02459 |
+5
-29
@@ -53,37 +53,13 @@ docker compose up -d
|
||||
|
||||
## Proxmox LXC Install
|
||||
|
||||
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
||||
You can now install Homelable with community-scripts (proxmox-VE) :
|
||||
|
||||
`https://community-scripts.org/scripts/homelable`
|
||||
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
||||
```
|
||||
|
||||
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
||||
|
||||
```bash
|
||||
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
||||
```
|
||||
|
||||
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
||||
|
||||
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
||||
> ```bash
|
||||
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||
> ```
|
||||
|
||||
### Update (LXC)
|
||||
|
||||
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
||||
|
||||
```bash
|
||||
sudo bash /opt/homelable/scripts/update.sh
|
||||
```
|
||||
|
||||
Or directly from GitHub:
|
||||
|
||||
```bash
|
||||
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app = FastAPI(
|
||||
title="Homelable API",
|
||||
version="1.8.0",
|
||||
version="1.8.3",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -57,8 +58,12 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
if sys.platform == "win32":
|
||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||
else:
|
||||
args = ["ping", "-c", "1", "-W", "1", host]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ping", "-c", "1", "-W", "1", host,
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.status_checker import _tcp_connect, check_node
|
||||
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||
|
||||
# --- check_node dispatcher ---
|
||||
|
||||
@@ -149,6 +149,48 @@ async def test_check_node_exception_returns_offline():
|
||||
assert result["response_time_ms"] is None
|
||||
|
||||
|
||||
# --- _ping platform args ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_uses_unix_args_on_non_windows():
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("192.168.1.1")
|
||||
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
assert "-n" not in captured["args"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_uses_windows_args_on_win32():
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("192.168.1.1")
|
||||
|
||||
assert "-n" in captured["args"]
|
||||
assert "-w" in captured["args"]
|
||||
assert "-c" not in captured["args"]
|
||||
|
||||
|
||||
# --- _tcp_connect ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Generated
+790
-875
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -53,7 +53,7 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
|
||||
+42
-3
@@ -44,6 +44,8 @@ export default function App() {
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||
@@ -355,6 +357,13 @@ export default function App() {
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||
|
||||
const handleClearWaypoints = useCallback(() => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
updateEdge(editEdgeId, { waypoints: [] })
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||
|
||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||
|
||||
@@ -370,6 +379,8 @@ export default function App() {
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onSave={handleSave}
|
||||
onNodeApproved={setEditNodeId}
|
||||
forceView={sidebarForceView}
|
||||
highlightPendingId={highlightPendingId}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Toolbar
|
||||
@@ -386,7 +397,19 @@ export default function App() {
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||
<CanvasContainer
|
||||
onConnect={handleEdgeConnect}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => {
|
||||
setHighlightPendingId(deviceId)
|
||||
setSidebarForceView('pending')
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||
</div>
|
||||
@@ -430,6 +453,7 @@ export default function App() {
|
||||
onClose={() => setEditEdgeId(null)}
|
||||
onSubmit={handleEdgeUpdate}
|
||||
onDelete={handleEdgeDelete}
|
||||
onClearWaypoints={handleClearWaypoints}
|
||||
initial={editEdge?.data}
|
||||
title="Edit Link"
|
||||
/>
|
||||
@@ -438,7 +462,11 @@ export default function App() {
|
||||
<ScanConfigModal
|
||||
open={scanConfigOpen}
|
||||
onClose={() => setScanConfigOpen(false)}
|
||||
onScanNow={() => toast.success('Scan triggered')}
|
||||
onScanNow={() => {
|
||||
toast.success('Network scan started — check Scan History for results')
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => setSidebarForceView('history'), 0)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -484,7 +512,18 @@ export default function App() {
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
/>
|
||||
|
||||
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<SearchModal
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => {
|
||||
setHighlightPendingId(deviceId)
|
||||
setSidebarForceView('pending')
|
||||
}, 0)
|
||||
}}
|
||||
/>
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
|
||||
@@ -26,9 +26,10 @@ interface CanvasContainerProps {
|
||||
onConnect?: (connection: Connection) => void
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
@@ -89,7 +90,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
selectionMode={SelectionMode.Partial}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
snapToGrid
|
||||
snapGrid={[16, 16]}
|
||||
snapGrid={[8, 8]}
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
@@ -97,11 +98,11 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
gap={16}
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<SearchBar />
|
||||
<SearchBar onOpenPending={onOpenPending} />
|
||||
<Controls>
|
||||
<ControlButton
|
||||
onClick={() => setLassoMode((m) => !m)}
|
||||
|
||||
@@ -2,15 +2,27 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { NODE_TYPE_LABELS } from '@/types'
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
export function SearchBar() {
|
||||
interface SearchBarProps {
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const { nodes, setSelectedNode } = useCanvasStore()
|
||||
const { setCenter } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
@@ -31,7 +43,7 @@ export function SearchBar() {
|
||||
}, [open])
|
||||
|
||||
const q = query.toLowerCase().trim()
|
||||
const results = q
|
||||
const nodeResults = q
|
||||
? nodes.filter((n) => {
|
||||
if (n.data.type === 'groupRect') return false
|
||||
return (
|
||||
@@ -43,6 +55,19 @@ export function SearchBar() {
|
||||
})
|
||||
: []
|
||||
|
||||
const pendingResults = q
|
||||
? pendingDevices.filter((d) =>
|
||||
d.ip.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.services.some((s) =>
|
||||
s.service_name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
)
|
||||
).slice(0, 4)
|
||||
: []
|
||||
|
||||
const totalResults = nodeResults.length + pendingResults.length
|
||||
|
||||
const goToNode = (id: string) => {
|
||||
const node = nodes.find((n) => n.id === id)
|
||||
if (!node) return
|
||||
@@ -101,7 +126,7 @@ export function SearchBar() {
|
||||
/>
|
||||
{query && (
|
||||
<span style={{ fontSize: 11, color: '#6e7681', flexShrink: 0 }}>
|
||||
{results.length} result{results.length !== 1 ? 's' : ''}
|
||||
{totalResults} result{totalResults !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -113,9 +138,9 @@ export function SearchBar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
{totalResults > 0 && (
|
||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||
{results.map((n) => (
|
||||
{nodeResults.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => goToNode(n.id)}
|
||||
@@ -146,10 +171,43 @@ export function SearchBar() {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||
<div style={{ height: 1, background: '#30363d', margin: '2px 0' }} />
|
||||
)}
|
||||
{pendingResults.map((d) => {
|
||||
const serviceName = d.services.find((s) => s.service_name)?.service_name
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => { onOpenPending?.(d.id); setOpen(false); setQuery('') }}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '7px 12px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#21262d')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{d.hostname ?? d.ip}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||
{serviceName ?? d.ip}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q && results.length === 0 && (
|
||||
{q && totalResults === 0 && (
|
||||
<div style={{ borderTop: '1px solid #30363d', padding: '10px 12px', fontSize: 12, color: '#6e7681', textAlign: 'center' }}>
|
||||
No results for “{query}”
|
||||
</div>
|
||||
|
||||
@@ -142,9 +142,9 @@ describe('CanvasContainer', () => {
|
||||
expect(rfProps.snapToGrid).toBe(true)
|
||||
})
|
||||
|
||||
it('sets snapGrid to [16, 16]', () => {
|
||||
it('sets snapGrid to [8, 8]', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.snapGrid).toEqual([16, 16])
|
||||
expect(rfProps.snapGrid).toEqual([8, 8])
|
||||
})
|
||||
|
||||
// ── Delete key ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
|
||||
|
||||
describe('buildWaypointPath — bezier (default)', () => {
|
||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||
// With only 2 pts (src + target), catmull-rom = cubic bezier
|
||||
const path = buildWaypointPath(0, 0, [], 100, 100)
|
||||
expect(path).toMatch(/^M 0 0 C/)
|
||||
})
|
||||
|
||||
it('routes through a single waypoint with smooth curve', () => {
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100)
|
||||
expect(path).toMatch(/^M 0 0 C/)
|
||||
// Should not be a straight polyline
|
||||
expect(path).not.toContain(' L ')
|
||||
})
|
||||
|
||||
it('routes through multiple waypoints', () => {
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100)
|
||||
expect(path).toMatch(/^M 0 0 C/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWaypointPath — smooth style', () => {
|
||||
it('builds a direct straight line with no waypoints (no bend)', () => {
|
||||
// Only 2 points → no intermediate vertex → no rounding needed
|
||||
expect(buildWaypointPath(0, 0, [], 100, 100, 'smooth')).toBe('M 0 0 L 100 100')
|
||||
})
|
||||
|
||||
it('routes through a single waypoint with straight lines (no intermediate bend)', () => {
|
||||
// 3 pts: src → wp → target — only 1 intermediate → rounded corners at wp
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }], 100, 100, 'smooth')
|
||||
// Should start at source and end at target
|
||||
expect(path).toMatch(/^M 0 0/)
|
||||
expect(path).toMatch(/100 100$/)
|
||||
// Should contain a quadratic bezier at the waypoint corner
|
||||
expect(path).toContain('Q')
|
||||
})
|
||||
|
||||
it('routes through multiple waypoints with rounded corners', () => {
|
||||
const path = buildWaypointPath(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
|
||||
expect(path).toMatch(/^M 0 0/)
|
||||
expect(path).toMatch(/100 100$/)
|
||||
expect(path).toContain('Q')
|
||||
})
|
||||
|
||||
it('does not round corners when segment is too short (r clamped to 0)', () => {
|
||||
// Adjacent waypoints very close together — r → 0, falls back to L
|
||||
const path = buildWaypointPath(0, 0, [{ x: 1, y: 0 }, { x: 2, y: 0 }], 100, 0, 'smooth')
|
||||
expect(path).toMatch(/^M 0 0/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snap45', () => {
|
||||
// Use positions very close to a 45° angle so deviation < SNAP_THRESHOLD (15px)
|
||||
it('snaps horizontal direction when close (deviation < threshold)', () => {
|
||||
// (100, 3) — nearly horizontal, deviation from 0° ≈ 3px → snaps
|
||||
const r = snap45({ x: 0, y: 0 }, { x: 100, y: 3 })
|
||||
expect(r.y).toBe(0)
|
||||
expect(r.x).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('snaps vertical direction when close', () => {
|
||||
const r = snap45({ x: 0, y: 0 }, { x: 3, y: 100 })
|
||||
expect(r.x).toBe(0)
|
||||
expect(r.y).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('snaps 45° diagonal when close', () => {
|
||||
// (80, 83) — nearly 45°, deviation ≈ 2px → snaps
|
||||
const r = snap45({ x: 0, y: 0 }, { x: 80, y: 83 })
|
||||
expect(r.x).toBe(r.y)
|
||||
})
|
||||
|
||||
it('does NOT snap when deviation exceeds threshold', () => {
|
||||
// (100, 40) — deviation from 0° is ~40px > 15 → no snap
|
||||
const pos = { x: 100, y: 40 }
|
||||
const r = snap45({ x: 0, y: 0 }, pos)
|
||||
expect(r).toEqual(pos)
|
||||
})
|
||||
|
||||
it('returns pos unchanged when distance < 1', () => {
|
||||
const pos = { x: 5, y: 5 }
|
||||
expect(snap45({ x: 5, y: 5 }, pos)).toBe(pos)
|
||||
})
|
||||
|
||||
it('preserves distance from origin when snapping', () => {
|
||||
const from = { x: 0, y: 0 }
|
||||
const pos = { x: 100, y: 3 } // close to horizontal
|
||||
const r = snap45(from, pos)
|
||||
const origDist = Math.hypot(pos.x - from.x, pos.y - from.y)
|
||||
const snapDist = Math.hypot(r.x - from.x, r.y - from.y)
|
||||
expect(snapDist).toBeCloseTo(origDist, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snap45both', () => {
|
||||
it('finds intersection satisfying 45° from both adjacent points (axis-aligned)', () => {
|
||||
// prev=(0,0), next=(100,100): diagonal — midpoint (50,50) should satisfy both
|
||||
const r = snap45both({ x: 0, y: 0 }, { x: 100, y: 100 }, { x: 50, y: 50 })
|
||||
// Result must be on a 45°-ray from (0,0)
|
||||
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
|
||||
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
|
||||
// Result must be on a 45°-ray from (100,100)
|
||||
const a2 = Math.atan2(r.y - 100, r.x - 100) / (Math.PI / 4)
|
||||
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
|
||||
})
|
||||
|
||||
it('snaps so both incoming and outgoing segments are at 45° when within threshold', () => {
|
||||
// prev=(0,0), next=(200,0) — valid intersection at (100,100) (45° from each)
|
||||
// pos=(100,93) is 7px away → within 15px threshold → should snap to (100,100)
|
||||
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, { x: 100, y: 93 })
|
||||
const a1 = Math.atan2(r.y - 0, r.x - 0) / (Math.PI / 4)
|
||||
expect(Math.abs(a1 - Math.round(a1))).toBeLessThan(0.05)
|
||||
const a2 = Math.atan2(r.y - 0, r.x - 200) / (Math.PI / 4)
|
||||
expect(Math.abs(a2 - Math.round(a2))).toBeLessThan(0.05)
|
||||
})
|
||||
|
||||
it('returns raw pos when beyond threshold', () => {
|
||||
// pos=(100,80) is 20px from nearest intersection (100,100) → no snap
|
||||
const pos = { x: 100, y: 80 }
|
||||
const r = snap45both({ x: 0, y: 0 }, { x: 200, y: 0 }, pos)
|
||||
expect(r).toEqual(pos)
|
||||
})
|
||||
|
||||
it('falls back gracefully when prev === next', () => {
|
||||
// No valid intersection → fallback to snap45
|
||||
const r = snap45both({ x: 50, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 90 })
|
||||
expect(r).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('distToSegment', () => {
|
||||
it('returns 0 when point is on the segment', () => {
|
||||
expect(distToSegment({ x: 50, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('returns perpendicular distance when point is beside segment', () => {
|
||||
expect(distToSegment({ x: 50, y: 10 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(10)
|
||||
})
|
||||
|
||||
it('returns distance to nearest endpoint when point is past the segment', () => {
|
||||
expect(distToSegment({ x: 200, y: 0 }, { x: 0, y: 0 }, { x: 100, y: 0 })).toBeCloseTo(100)
|
||||
})
|
||||
|
||||
it('handles zero-length segment (a === b)', () => {
|
||||
expect(distToSegment({ x: 3, y: 4 }, { x: 0, y: 0 }, { x: 0, y: 0 })).toBeCloseTo(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findInsertIndex', () => {
|
||||
it('returns 0 when there are no waypoints (only one segment)', () => {
|
||||
expect(findInsertIndex(0, 0, [], 100, 0, { x: 50, y: 5 })).toBe(0)
|
||||
})
|
||||
|
||||
it('inserts before first waypoint when click is on first segment', () => {
|
||||
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 30, y: 5 })
|
||||
expect(idx).toBe(0)
|
||||
})
|
||||
|
||||
it('inserts after first waypoint when click is on second segment', () => {
|
||||
const idx = findInsertIndex(0, 0, [{ x: 100, y: 0 }], 200, 0, { x: 160, y: 5 })
|
||||
expect(idx).toBe(1)
|
||||
})
|
||||
|
||||
it('picks the closest segment among multiple', () => {
|
||||
const idx = findInsertIndex(
|
||||
0, 0,
|
||||
[{ x: 100, y: 0 }, { x: 100, y: 100 }],
|
||||
200, 100,
|
||||
{ x: 150, y: 105 },
|
||||
)
|
||||
expect(idx).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getBezierPath,
|
||||
getSmoothStepPath,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
type EdgeProps,
|
||||
type Edge,
|
||||
} from '@xyflow/react'
|
||||
import type { EdgeData, EdgeType } from '@/types'
|
||||
import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
||||
|
||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||
|
||||
@@ -18,6 +22,165 @@ function getVlanColor(vlanId?: number): string {
|
||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||
}
|
||||
|
||||
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||
|
||||
interface WaypointHandleProps {
|
||||
edgeId: string
|
||||
index: number
|
||||
waypoint: Waypoint
|
||||
waypoints: Waypoint[]
|
||||
color: string
|
||||
pathStyle?: string
|
||||
prevPoint: Waypoint
|
||||
nextPoint: Waypoint
|
||||
}
|
||||
|
||||
function WaypointHandle({ edgeId, index, waypoint, waypoints, color, pathStyle, prevPoint, nextPoint }: WaypointHandleProps) {
|
||||
const { screenToFlowPosition } = useReactFlow()
|
||||
const updateEdge = useCanvasStore((s) => s.updateEdge)
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (e.buttons !== 1) return
|
||||
let pos = screenToFlowPosition({ x: e.clientX, y: e.clientY })
|
||||
if (pathStyle === 'smooth') {
|
||||
// Find the intersection of 45°-rays from both adjacent points so that
|
||||
// ALL segments (prev→this and this→next) snap to 45° simultaneously.
|
||||
pos = snap45both(prevPoint, nextPoint, pos)
|
||||
}
|
||||
const next = [...waypoints]
|
||||
next[index] = pos
|
||||
updateEdge(edgeId, { waypoints: next })
|
||||
}, [screenToFlowPosition, waypoints, index, edgeId, updateEdge, pathStyle, prevPoint, nextPoint])
|
||||
|
||||
const handlePointerUp = useCallback((e: React.PointerEvent) => {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
updateEdge(edgeId, { waypoints: waypoints.filter((_, i) => i !== index) })
|
||||
}, [edgeId, waypoints, index, updateEdge])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${waypoint.x}px, ${waypoint.y}px)`,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
border: '2px solid #0d1117',
|
||||
cursor: 'grab',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 10,
|
||||
}}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
title="Drag to move · Double-click to remove"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Add waypoint handle (+ button at segment midpoints) ──────────────────────
|
||||
|
||||
interface AddWaypointHandleProps {
|
||||
edgeId: string
|
||||
insertIndex: number
|
||||
x: number
|
||||
y: number
|
||||
waypoints: Waypoint[]
|
||||
color: string
|
||||
pathStyle?: string
|
||||
prevPoint: Waypoint
|
||||
}
|
||||
|
||||
function AddWaypointHandle({ edgeId, insertIndex, x, y, waypoints, color, pathStyle, prevPoint }: AddWaypointHandleProps) {
|
||||
const updateEdge = useCanvasStore((s) => s.updateEdge)
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
let pos = { x, y }
|
||||
if (pathStyle === 'smooth') pos = snap45(prevPoint, pos)
|
||||
const next = [...waypoints.slice(0, insertIndex), pos, ...waypoints.slice(insertIndex)]
|
||||
updateEdge(edgeId, { waypoints: next })
|
||||
}, [edgeId, insertIndex, x, y, waypoints, updateEdge, pathStyle, prevPoint])
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${x}px, ${y}px)`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
background: '#0d1117',
|
||||
border: `1.5px solid ${color}`,
|
||||
color,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
cursor: 'crosshair',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 9,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
title="Click to add waypoint"
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Segment midpoints ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute + handle positions for each path segment.
|
||||
* For smooth style: bias the first + handle to the source handle axis and the
|
||||
* last + handle to the target handle axis, so clicking always gives a clean
|
||||
* perpendicular exit/entry (no diagonal guesswork near the nodes).
|
||||
*/
|
||||
function segmentMidpoints(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
pathStyle?: string,
|
||||
sourcePosition?: string,
|
||||
): { x: number; y: number; insertIndex: number }[] {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
const isSmooth = pathStyle === 'smooth'
|
||||
|
||||
return pts.slice(0, -1).map((a, i) => {
|
||||
const b = pts[i + 1]
|
||||
let mx = (a.x + b.x) / 2
|
||||
const my = (a.y + b.y) / 2
|
||||
|
||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||
// the source handle axis so clicking it creates a perpendicular exit.
|
||||
// Only applies to bottom/top handles (vertical exits) and only when the edge
|
||||
// has no waypoints yet — once waypoints exist, all + handles stay at the
|
||||
// real segment midpoint so they remain visually on the edge.
|
||||
if (isSmooth && i === 0 && pts.length === 2) {
|
||||
const vertSrc = sourcePosition === 'bottom' || sourcePosition === 'top'
|
||||
if (vertSrc) mx = a.x // same X as source → + sits directly below/above node
|
||||
}
|
||||
|
||||
return { x: mx, y: my, insertIndex: i }
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main edge component ──────────────────────────────────────────────────────
|
||||
|
||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -25,11 +188,26 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||
|
||||
const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0
|
||||
? data.waypoints as Waypoint[]
|
||||
: []
|
||||
|
||||
const hasWaypoints = waypoints.length > 0
|
||||
|
||||
const pathStyle = data?.path_style as string | undefined
|
||||
|
||||
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
|
||||
const [edgePath, labelX] = data?.path_style === 'smooth'
|
||||
const [autoPath, labelX] = pathStyle === 'smooth'
|
||||
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
|
||||
: getBezierPath(pathArgs)
|
||||
|
||||
const edgePath = hasWaypoints
|
||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||
: autoPath
|
||||
|
||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
||||
const midY = (sourceY + targetY) / 2
|
||||
|
||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||
const edgeColors = theme.colors.edgeColors
|
||||
|
||||
@@ -43,6 +221,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
}
|
||||
|
||||
const customColor = data?.custom_color as string | undefined
|
||||
const strokeColor: string = selected
|
||||
? theme.colors.edgeSelectedColor
|
||||
: customColor
|
||||
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...BASE_STYLES[edgeType],
|
||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||
@@ -50,16 +233,20 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||
}
|
||||
|
||||
// Normalize animated value — supports legacy boolean (true → 'snake')
|
||||
const animMode: 'none' | 'snake' | 'flow' =
|
||||
data?.animated === true || data?.animated === 'snake' ? 'snake' :
|
||||
data?.animated === 'flow' ? 'flow' : 'none'
|
||||
|
||||
const animColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||
|
||||
const midpoints = selected
|
||||
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} path={edgePath} style={style} />
|
||||
<BaseEdge id={id} path={edgePath} style={style} interactionWidth={16} />
|
||||
|
||||
{animMode === 'snake' && (
|
||||
<path
|
||||
d={edgePath}
|
||||
@@ -92,12 +279,12 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
</path>
|
||||
)}
|
||||
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<EdgeLabelRenderer>
|
||||
{data?.label && (
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${(sourceY + targetY) / 2}px)`,
|
||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
color: theme.colors.edgeLabelColor,
|
||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||
@@ -105,8 +292,47 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
>
|
||||
{data.label as string}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Existing waypoint drag handles */}
|
||||
{selected && waypoints.map((wp, idx) => {
|
||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||
const nextPoint = idx === waypoints.length - 1 ? { x: targetX, y: targetY } : waypoints[idx + 1]
|
||||
return (
|
||||
<WaypointHandle
|
||||
key={`wp-${idx}`}
|
||||
edgeId={id}
|
||||
index={idx}
|
||||
waypoint={wp}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
nextPoint={nextPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* + handles at segment midpoints to add new waypoints */}
|
||||
{selected && midpoints.map((mp) => {
|
||||
const prevPoint = mp.insertIndex === 0
|
||||
? { x: sourceX, y: sourceY }
|
||||
: waypoints[mp.insertIndex - 1]
|
||||
return (
|
||||
<AddWaypointHandle
|
||||
key={`add-${mp.insertIndex}`}
|
||||
edgeId={id}
|
||||
insertIndex={mp.insertIndex}
|
||||
x={mp.x}
|
||||
y={mp.y}
|
||||
waypoints={waypoints}
|
||||
color={strokeColor}
|
||||
pathStyle={pathStyle}
|
||||
prevPoint={prevPoint}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Waypoint } from '@/types'
|
||||
|
||||
// ── Path builders ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Catmull-Rom → cubic bezier for smooth curves through waypoints */
|
||||
function buildCatmullRomPath(pts: Waypoint[]): string {
|
||||
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const p0 = pts[Math.max(i - 1, 0)]
|
||||
const p1 = pts[i]
|
||||
const p2 = pts[i + 1]
|
||||
const p3 = pts[Math.min(i + 2, pts.length - 1)]
|
||||
const cp1x = p1.x + (p2.x - p0.x) / 6
|
||||
const cp1y = p1.y + (p2.y - p0.y) / 6
|
||||
const cp2x = p2.x - (p3.x - p1.x) / 6
|
||||
const cp2y = p2.y - (p3.y - p1.y) / 6
|
||||
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${p2.x} ${p2.y}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
/** Polyline with rounded corners at each waypoint vertex (quadratic bezier) */
|
||||
function buildRoundedPolylinePath(pts: Waypoint[], radius = 8): string {
|
||||
if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`
|
||||
if (pts.length === 2) return `M ${pts[0].x} ${pts[0].y} L ${pts[1].x} ${pts[1].y}`
|
||||
|
||||
let d = `M ${pts[0].x} ${pts[0].y}`
|
||||
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const prev = pts[i - 1]
|
||||
const curr = pts[i]
|
||||
const next = pts[i + 1]
|
||||
|
||||
const dx1 = curr.x - prev.x
|
||||
const dy1 = curr.y - prev.y
|
||||
const len1 = Math.hypot(dx1, dy1)
|
||||
|
||||
const dx2 = next.x - curr.x
|
||||
const dy2 = next.y - curr.y
|
||||
const len2 = Math.hypot(dx2, dy2)
|
||||
|
||||
if (len1 < 1 || len2 < 1) {
|
||||
d += ` L ${curr.x} ${curr.y}`
|
||||
continue
|
||||
}
|
||||
|
||||
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||
|
||||
// Approach point (on segment prev→curr, r units before corner)
|
||||
const bx = curr.x - (dx1 / len1) * r
|
||||
const by = curr.y - (dy1 / len1) * r
|
||||
|
||||
// Departure point (on segment curr→next, r units after corner)
|
||||
const ax = curr.x + (dx2 / len2) * r
|
||||
const ay = curr.y + (dy2 / len2) * r
|
||||
|
||||
d += ` L ${bx} ${by} Q ${curr.x} ${curr.y} ${ax} ${ay}`
|
||||
}
|
||||
|
||||
d += ` L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`
|
||||
return d
|
||||
}
|
||||
|
||||
export function buildWaypointPath(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
pathStyle: string = 'bezier',
|
||||
): string {
|
||||
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||
}
|
||||
|
||||
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Snap `pos` to the nearest 45°-multiple direction from `from`.
|
||||
* Only snaps when within SNAP_THRESHOLD px of a 45° position.
|
||||
*/
|
||||
export function snap45(from: Waypoint, pos: Waypoint): Waypoint {
|
||||
const dx = pos.x - from.x
|
||||
const dy = pos.y - from.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
if (dist < 1) return pos
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||
const candidate = {
|
||||
x: Math.round(from.x + dist * Math.cos(snapped)),
|
||||
y: Math.round(from.y + dist * Math.sin(snapped)),
|
||||
}
|
||||
const deviation = Math.hypot(candidate.x - pos.x, candidate.y - pos.y)
|
||||
return deviation <= SNAP_THRESHOLD ? candidate : pos
|
||||
}
|
||||
|
||||
/** Snap threshold in flow-space pixels. Only snap when this close to a 45° position. */
|
||||
const SNAP_THRESHOLD = 15
|
||||
|
||||
/**
|
||||
* Find the position closest to `pos` that lies simultaneously on a 45°-ray
|
||||
* from `prev` AND on a 45°-ray from `next`.
|
||||
*
|
||||
* Only snaps when the nearest valid intersection is within SNAP_THRESHOLD px —
|
||||
* outside that zone the raw drag position is returned, allowing free placement.
|
||||
*/
|
||||
export function snap45both(prev: Waypoint, next: Waypoint, pos: Waypoint): Waypoint {
|
||||
let best: Waypoint | null = null
|
||||
let bestDist = Infinity
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const a1 = i * Math.PI / 4
|
||||
const c1 = Math.cos(a1), s1 = Math.sin(a1)
|
||||
|
||||
for (let j = 0; j < 8; j++) {
|
||||
const a2 = j * Math.PI / 4
|
||||
const c2 = Math.cos(a2), s2 = Math.sin(a2)
|
||||
|
||||
const dx = next.x - prev.x
|
||||
const dy = next.y - prev.y
|
||||
const det = -c1 * s2 + c2 * s1
|
||||
if (Math.abs(det) < 1e-6) continue
|
||||
|
||||
const t = (-dx * s2 + c2 * dy) / det
|
||||
const s = (c1 * dy - s1 * dx) / det
|
||||
if (t < -1e-6 || s < -1e-6) continue
|
||||
|
||||
const ix = prev.x + t * c1
|
||||
const iy = prev.y + t * s1
|
||||
const d = Math.hypot(ix - pos.x, iy - pos.y)
|
||||
if (d < bestDist) {
|
||||
bestDist = d
|
||||
best = { x: Math.round(ix), y: Math.round(iy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only snap if close enough — otherwise let the waypoint move freely
|
||||
if (best === null || bestDist > SNAP_THRESHOLD) return pos
|
||||
return best
|
||||
}
|
||||
|
||||
// ── Geometry helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function distToSegment(p: Waypoint, a: Waypoint, b: Waypoint): number {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const lenSq = dx * dx + dy * dy
|
||||
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y)
|
||||
const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq))
|
||||
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy))
|
||||
}
|
||||
|
||||
export function findInsertIndex(
|
||||
sourceX: number, sourceY: number,
|
||||
waypoints: Waypoint[],
|
||||
targetX: number, targetY: number,
|
||||
point: Waypoint,
|
||||
): number {
|
||||
const allPts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||
let minDist = Infinity
|
||||
let best = 0
|
||||
for (let i = 0; i < allPts.length - 1; i++) {
|
||||
const d = distToSegment(point, allPts[i], allPts[i + 1])
|
||||
if (d < minDist) { minDist = d; best = i }
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -39,11 +39,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
style={{
|
||||
background: colors.background,
|
||||
borderColor: colors.border,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
boxShadow: isOnline
|
||||
borderWidth: 1,
|
||||
boxShadow: isOnline && selected
|
||||
? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||
: isOnline
|
||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||
: selected
|
||||
? `0 0 8px ${colors.border}44`
|
||||
? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||
: 'none',
|
||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||
minWidth: 140,
|
||||
@@ -55,7 +57,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
isVisible={selected}
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
/>
|
||||
<Handle
|
||||
|
||||
@@ -73,7 +73,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
background: '#00d4ff',
|
||||
border: '1px solid #0d1117',
|
||||
}}
|
||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
@@ -86,7 +86,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
justifyContent: posStyle.justifyContent,
|
||||
padding: 12,
|
||||
background: backgroundColor,
|
||||
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||
borderRadius: 10,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
|
||||
@@ -23,11 +23,12 @@ interface EdgeModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: EdgeData) => void
|
||||
onDelete?: () => void
|
||||
onClearWaypoints?: () => void
|
||||
initial?: Partial<EdgeData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
||||
const [label, setLabel] = useState(initial?.label ?? '')
|
||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||
@@ -175,6 +176,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClearWaypoints(); onClose() }}
|
||||
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
|
||||
>
|
||||
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||
|
||||
@@ -78,7 +78,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
|
||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||
|
||||
const handleApprove = () => { onApprove(device); onClose() }
|
||||
const handleApprove = () => { onApprove(device) }
|
||||
const handleHide = () => { onHide(device); onClose() }
|
||||
const handleIgnore = () => { onIgnore(device); onClose() }
|
||||
|
||||
@@ -105,7 +105,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
||||
{device.discovery_source && (
|
||||
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
|
||||
)}
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at).toLocaleString()} />
|
||||
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
|
||||
@@ -24,29 +24,22 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro
|
||||
.catch(() => {/* use defaults */})
|
||||
}, [open])
|
||||
|
||||
const handleSave = async () => {
|
||||
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 }
|
||||
setSaving(true)
|
||||
try {
|
||||
await scanApi.saveConfig({ ranges: cleaned })
|
||||
toast.success('Scan config saved')
|
||||
await scanApi.trigger()
|
||||
onScanNow()
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error('Failed to save config')
|
||||
toast.error('Failed to start scan')
|
||||
} 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">
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenPending: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||
export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [pendingDevices, setPendingDevices] = useState<PendingDevice[]>([])
|
||||
const nodes = useCanvasStore((s) => s.nodes)
|
||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||
const { fitView } = useReactFlow()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {})
|
||||
}, [open])
|
||||
|
||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
const q = query.toLowerCase()
|
||||
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
||||
|
||||
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
|
||||
n.data.label?.toLowerCase().includes(q) ||
|
||||
n.data.ip?.toLowerCase().includes(q) ||
|
||||
n.data.hostname?.toLowerCase().includes(q)
|
||||
).slice(0, 8)
|
||||
).slice(0, 6)
|
||||
|
||||
const handleSelect = useCallback((nodeId: string) => {
|
||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||
d.ip.toLowerCase().includes(q) ||
|
||||
d.hostname?.toLowerCase().includes(q) ||
|
||||
d.services.some((s) =>
|
||||
s.service_name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
)
|
||||
).slice(0, 4)
|
||||
|
||||
const totalResults = nodeResults.length + pendingResults.length
|
||||
|
||||
const handleSelectNode = useCallback((nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||
onClose()
|
||||
setQuery('')
|
||||
}, [fitView, setSelectedNode, onClose])
|
||||
|
||||
const handleSelectPending = useCallback((deviceId: string) => {
|
||||
onOpenPending(deviceId)
|
||||
onClose()
|
||||
setQuery('')
|
||||
}, [onOpenPending, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
@@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search nodes by label, IP, hostname…"
|
||||
placeholder="Search nodes, pending devices by IP or service…"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
||||
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
|
||||
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id)
|
||||
}}
|
||||
/>
|
||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ul className="py-1 max-h-64 overflow-y-auto">
|
||||
{results.map((node) => (
|
||||
{totalResults > 0 && (
|
||||
<ul className="py-1 max-h-72 overflow-y-auto">
|
||||
{nodeResults.map((node) => (
|
||||
<li
|
||||
key={node.id}
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelect(node.id)}
|
||||
onClick={() => handleSelectNode(node.id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{pendingResults.length > 0 && nodeResults.length > 0 && (
|
||||
<li className="px-4 py-1">
|
||||
<div className="h-px bg-border" />
|
||||
</li>
|
||||
)}
|
||||
{pendingResults.map((device) => {
|
||||
const serviceName = device.services.find((s) => s.service_name)?.service_name
|
||||
return (
|
||||
<li
|
||||
key={device.id}
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelectPending(device.id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
|
||||
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{q.length > 0 && results.length === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
||||
{q.length > 0 && totalResults === 0 && (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
|
||||
)}
|
||||
|
||||
{q.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices…</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,4 +187,55 @@ describe('EdgeModal', () => {
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Waypoints / Clear path ────────────────────────────────────────────────
|
||||
|
||||
it('does not show Clear path button when onClearWaypoints is not provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when waypoints are empty', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when no initial waypoints', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Clear path button with count when waypoints exist', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows singular "point" when only one waypoint', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (1 point)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
|
||||
const onClearWaypoints = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Clear path (1 point)'))
|
||||
expect(onClearWaypoints).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('PendingDeviceModal', () => {
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls onApprove with the device and onClose when Approve is clicked', () => {
|
||||
it('calls onApprove with the device when Approve is clicked (parent controls close on success)', () => {
|
||||
const device = makeDevice()
|
||||
const onApprove = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
@@ -135,7 +135,7 @@ describe('PendingDeviceModal', () => {
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Approve' }))
|
||||
expect(onApprove).toHaveBeenCalledWith(device)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onHide with the device and onClose when Hide is clicked', () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
getConfig: vi.fn(),
|
||||
saveConfig: vi.fn(),
|
||||
trigger: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||
@@ -20,6 +21,8 @@ describe('ScanConfigModal', () => {
|
||||
vi.mocked(scanApi.getConfig).mockResolvedValue(defaultConfig as never)
|
||||
vi.mocked(scanApi.saveConfig).mockReset()
|
||||
vi.mocked(scanApi.saveConfig).mockResolvedValue({} as never)
|
||||
vi.mocked(scanApi.trigger).mockReset()
|
||||
vi.mocked(scanApi.trigger).mockResolvedValue({} as never)
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
})
|
||||
@@ -72,7 +75,7 @@ describe('ScanConfigModal', () => {
|
||||
expect(scanApi.saveConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onScanNow after saving on "Scan Now" click', async () => {
|
||||
it('saves config, triggers scan, calls onScanNow and closes on "Scan Now" click', async () => {
|
||||
const onScanNow = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(<ScanConfigModal open onClose={onClose} onScanNow={onScanNow} />)
|
||||
@@ -80,7 +83,9 @@ describe('ScanConfigModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Now' }))
|
||||
await waitFor(() => {
|
||||
expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] })
|
||||
expect(scanApi.trigger).toHaveBeenCalledOnce()
|
||||
expect(onScanNow).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
|
||||
useReactFlow: () => ({ fitView: mockFitView }),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
|
||||
}))
|
||||
|
||||
const mockOnOpenPending = vi.fn()
|
||||
|
||||
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
|
||||
return {
|
||||
id,
|
||||
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
|
||||
})
|
||||
|
||||
it('renders nothing when closed', () => {
|
||||
render(<SearchModal open={false} onClose={vi.fn()} />)
|
||||
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders search input when open', () => {
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows "Type to search" hint when query is empty', () => {
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
expect(screen.getByText(/type to search/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows no results message when query has no matches', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
|
||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
||||
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters nodes by label', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
|
||||
expect(screen.getByText('My Router')).toBeDefined()
|
||||
expect(screen.queryByText('NAS Server')).toBeNull()
|
||||
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
|
||||
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
|
||||
expect(screen.getByText('Box A')).toBeDefined()
|
||||
expect(screen.queryByText('Box B')).toBeNull()
|
||||
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
|
||||
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
|
||||
expect(screen.getByText('A')).toBeDefined()
|
||||
expect(screen.queryByText('B')).toBeNull()
|
||||
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
|
||||
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
|
||||
],
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
|
||||
expect(screen.getByText(/no nodes match/i)).toBeDefined()
|
||||
expect(screen.getByText(/no results match/i)).toBeDefined()
|
||||
})
|
||||
|
||||
it('limits results to 8 nodes', () => {
|
||||
it('limits node results to 6', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
|
||||
})
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
|
||||
const items = screen.getAllByText(/Server \d/)
|
||||
expect(items).toHaveLength(8)
|
||||
expect(items).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('selects node and closes on result click', () => {
|
||||
const onClose = vi.fn()
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
|
||||
fireEvent.click(screen.getByText('Proxmox'))
|
||||
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
|
||||
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
|
||||
it('selects first result and closes on Enter key', () => {
|
||||
const onClose = vi.fn()
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
const input = screen.getByPlaceholderText(/search nodes/i)
|
||||
fireEvent.change(input, { target: { value: 'switch' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
|
||||
|
||||
it('closes on Escape key', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('closes when clicking backdrop', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
// The backdrop is the fixed inset div — clicking it fires onClose
|
||||
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
|
||||
fireEvent.click(backdrop)
|
||||
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
|
||||
|
||||
it('does not close when clicking inside the search box', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SearchModal open onClose={onClose} />)
|
||||
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('search is case-insensitive', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
|
||||
render(<SearchModal open onClose={vi.fn()} />)
|
||||
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
|
||||
expect(screen.getByText('My NAS')).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -85,6 +85,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const handleAddService = () => {
|
||||
const port = parseInt(newSvc.port, 10)
|
||||
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
snapshotHistory()
|
||||
const svc: ServiceInfo = { port, protocol: newSvc.protocol, service_name: newSvc.service_name.trim() }
|
||||
updateNode(node.id, { services: [...services, svc] })
|
||||
setNewSvc(EMPTY_FORM)
|
||||
@@ -92,6 +93,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
snapshotHistory()
|
||||
const updated = services.filter((_, i) => i !== index)
|
||||
updateNode(node.id, { services: updated })
|
||||
if (editingIndex === index) setEditingFor(null)
|
||||
@@ -109,6 +111,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
if (editingIndex === null) return
|
||||
const port = parseInt(editSvc.port, 10)
|
||||
if (!editSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||
snapshotHistory()
|
||||
const updated = services.map((svc, i) =>
|
||||
i === editingIndex ? { ...svc, port, protocol: editSvc.protocol, service_name: editSvc.service_name.trim() } : svc
|
||||
)
|
||||
@@ -147,7 +150,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />}
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
@@ -35,26 +36,26 @@ interface SidebarProps {
|
||||
onScan: () => void
|
||||
onSave: () => void
|
||||
onNodeApproved: (nodeId: string) => void
|
||||
forceView?: SidebarView
|
||||
highlightPendingId?: string
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||
const [_collapsed, setCollapsed] = useState(false)
|
||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||
|
||||
// When forceView is set, override local state without useEffect
|
||||
const collapsed = forceView ? false : _collapsed
|
||||
const activeView = forceView ?? _activeView
|
||||
|
||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||
|
||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
try {
|
||||
await scanApi.trigger()
|
||||
toast.success('Network scan started — check Scan History for results')
|
||||
setActiveView('history')
|
||||
onScan()
|
||||
} catch {
|
||||
toast.error('Failed to trigger scan')
|
||||
}
|
||||
const handleScan = useCallback(() => {
|
||||
onScan()
|
||||
}, [onScan])
|
||||
|
||||
return (
|
||||
@@ -92,7 +93,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
{/* 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 onNodeApproved={onNodeApproved} />}
|
||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
||||
{activeView === 'history' && <ScanHistoryPanel />}
|
||||
{activeView === 'settings' && <SettingsPanel />}
|
||||
@@ -152,15 +153,20 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && <VersionBadge />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: string) => void }) {
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
|
||||
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||
const { addNode, scanEventTs } = useCanvasStore()
|
||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -190,6 +196,11 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
if (scanEventTs > 0) load()
|
||||
}, [scanEventTs, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightId || loading) return
|
||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [highlightId, loading])
|
||||
|
||||
const handleApprove = async (device: PendingDevice) => {
|
||||
try {
|
||||
const nodeData = {
|
||||
@@ -210,6 +221,7 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
})
|
||||
toast.success(`Approved ${nodeData.label}`)
|
||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||
setSelected(null)
|
||||
onNodeApproved(nodeId)
|
||||
} catch {
|
||||
toast.error('Failed to approve device')
|
||||
@@ -256,7 +268,6 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const COMMON_PORTS = new Set([22, 80, 443])
|
||||
const namedService = d.services.find((s) => s.category != null && !COMMON_PORTS.has(s.port))
|
||||
const titleService = namedService
|
||||
?? d.services.find((s) => s.port === 80)
|
||||
@@ -271,11 +282,13 @@ function PendingDevicesPanel({ onNodeApproved }: { onNodeApproved: (nodeId: stri
|
||||
const virtualBadge = detectVirtualBadge(d.mac)
|
||||
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
||||
const isHighlighted = d.id === highlightId
|
||||
return (
|
||||
<button
|
||||
key={d.id}
|
||||
ref={isHighlighted ? highlightRef : null}
|
||||
onClick={() => setSelected(d)}
|
||||
className="w-full mb-1.5 p-2 rounded-md bg-[#21262d] text-xs text-left hover:bg-[#30363d] transition-colors border border-transparent hover:border-[#30363d]"
|
||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#e3b341] shrink-0" />
|
||||
@@ -467,7 +480,7 @@ function ScanHistoryPanel() {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{new Date(r.started_at).toLocaleString()}
|
||||
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
|
||||
</div>
|
||||
{r.ranges.length > 0 && (
|
||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||
@@ -538,6 +551,34 @@ function SettingsPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function VersionBadge() {
|
||||
const current = __APP_VERSION__
|
||||
const { latest, hasUpdate } = useLatestRelease(current)
|
||||
|
||||
return (
|
||||
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
|
||||
<a
|
||||
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
v{current}
|
||||
</a>
|
||||
{hasUpdate && latest && (
|
||||
<a
|
||||
href={latest.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
|
||||
>
|
||||
↑ v{latest.version} available
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||
|
||||
@@ -222,20 +222,12 @@ describe('Sidebar', () => {
|
||||
|
||||
// ── Scan action ────────────────────────────────────────────────────────────
|
||||
|
||||
it('calls scanApi.trigger and onScan prop when Scan Network is clicked', async () => {
|
||||
const { scanApi } = await import('@/api/client')
|
||||
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Scan Network'))
|
||||
await waitFor(() => expect(scanApi.trigger).toHaveBeenCalledOnce())
|
||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('switches to Scan History view after scan is triggered', async () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Scan Network'))
|
||||
await waitFor(() => expect(screen.getByText('History')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
// ── Mocks ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
trigger: vi.fn().mockResolvedValue({}),
|
||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
stop: vi.fn().mockResolvedValue({}),
|
||||
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
||||
},
|
||||
settingsApi: {
|
||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||
save: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: () => null,
|
||||
}))
|
||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
|
||||
vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
|
||||
|
||||
vi.mock('@/hooks/useLatestRelease', () => ({
|
||||
useLatestRelease: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderSidebar() {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
hasUnsavedChanges: false,
|
||||
hideIp: false,
|
||||
toggleHideIp: vi.fn(),
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||
|
||||
return render(
|
||||
<Sidebar
|
||||
onAddNode={vi.fn()}
|
||||
onAddGroupRect={vi.fn()}
|
||||
onScan={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onNodeApproved={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('VersionBadge', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false })
|
||||
})
|
||||
|
||||
it('displays the current app version', () => {
|
||||
renderSidebar()
|
||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links current version to its GitHub release page', () => {
|
||||
renderSidebar()
|
||||
const link = screen.getByText(`v${__APP_VERSION__}`).closest('a')
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
`https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`,
|
||||
)
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
|
||||
it('does not show update badge when on latest version', () => {
|
||||
renderSidebar()
|
||||
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows update badge when a newer version is available', async () => {
|
||||
vi.mocked(useLatestRelease).mockReturnValue({
|
||||
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
|
||||
hasUpdate: true,
|
||||
})
|
||||
renderSidebar()
|
||||
await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('update badge links to the latest release URL', async () => {
|
||||
vi.mocked(useLatestRelease).mockReturnValue({
|
||||
latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
|
||||
hasUpdate: true,
|
||||
})
|
||||
renderSidebar()
|
||||
await waitFor(() => {
|
||||
const badge = screen.getByText('↑ v9.9.9 available').closest('a')
|
||||
expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9')
|
||||
expect(badge).toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show update badge when hasUpdate is false even if latest exists', () => {
|
||||
vi.mocked(useLatestRelease).mockReturnValue({
|
||||
latest: { version: __APP_VERSION__, url: 'https://github.com' },
|
||||
hasUpdate: false,
|
||||
})
|
||||
renderSidebar()
|
||||
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
|
||||
// Reset module between tests so the module-level cache is cleared
|
||||
async function freshHook() {
|
||||
vi.resetModules()
|
||||
const mod = await import('../useLatestRelease')
|
||||
return mod.useLatestRelease
|
||||
}
|
||||
|
||||
const CURRENT = '1.8.3'
|
||||
|
||||
function mockFetch(payload: unknown, ok = true) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: () => Promise.resolve(payload),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('useLatestRelease', () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns no update when latest version matches current', async () => {
|
||||
mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' })
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await waitFor(() => expect(result.current.latest).not.toBeNull())
|
||||
expect(result.current.hasUpdate).toBe(false)
|
||||
})
|
||||
|
||||
it('returns update when latest version is newer', async () => {
|
||||
mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' })
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await waitFor(() => expect(result.current.hasUpdate).toBe(true))
|
||||
expect(result.current.latest?.version).toBe('1.9.0')
|
||||
expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0')
|
||||
})
|
||||
|
||||
it('strips leading v from tag_name', async () => {
|
||||
mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' })
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await waitFor(() => expect(result.current.latest).not.toBeNull())
|
||||
expect(result.current.latest?.version).toBe('2.0.0')
|
||||
})
|
||||
|
||||
it('does not show update when API returns non-ok response', async () => {
|
||||
mockFetch({ message: 'Not Found' }, false)
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(result.current.hasUpdate).toBe(false)
|
||||
expect(result.current.latest).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show update when API returns missing tag_name', async () => {
|
||||
mockFetch({ html_url: 'https://github.com/example' })
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(result.current.hasUpdate).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show update when API returns missing html_url', async () => {
|
||||
mockFetch({ tag_name: 'v2.0.0' })
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(result.current.hasUpdate).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show update when fetch throws', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
|
||||
const useLatestRelease = await freshHook()
|
||||
const { result } = renderHook(() => useLatestRelease(CURRENT))
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
expect(result.current.hasUpdate).toBe(false)
|
||||
})
|
||||
|
||||
it('fetches only once when hook is mounted multiple times concurrently', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const useLatestRelease = await freshHook()
|
||||
// Mount all three before the fetch resolves — cache is set to 'pending' after first mount
|
||||
const a = renderHook(() => useLatestRelease(CURRENT))
|
||||
const b = renderHook(() => useLatestRelease(CURRENT))
|
||||
const c = renderHook(() => useLatestRelease(CURRENT))
|
||||
await waitFor(() => {
|
||||
expect(a.result.current.latest).not.toBeNull()
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
// All hooks see the same result once cache resolves
|
||||
expect(b.result.current.hasUpdate).toBe(false)
|
||||
expect(c.result.current.hasUpdate).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface ReleaseInfo {
|
||||
version: string
|
||||
url: string
|
||||
}
|
||||
|
||||
let cache: ReleaseInfo | null | 'error' | 'pending' = null
|
||||
|
||||
export function useLatestRelease(currentVersion: string) {
|
||||
const [latest, setLatest] = useState<ReleaseInfo | null>(
|
||||
cache && cache !== 'error' && cache !== 'pending' ? cache : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (cache !== null) return
|
||||
cache = 'pending'
|
||||
fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) { cache = 'error'; return }
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
if (!data || typeof data.tag_name !== 'string' || !data.html_url) {
|
||||
cache = 'error'
|
||||
return
|
||||
}
|
||||
const version = data.tag_name.replace(/^v/, '')
|
||||
const info: ReleaseInfo = { version, url: data.html_url }
|
||||
cache = info
|
||||
setLatest(info)
|
||||
})
|
||||
.catch(() => {
|
||||
cache = 'error'
|
||||
})
|
||||
}, [])
|
||||
|
||||
const hasUpdate = latest !== null && latest.version !== currentVersion
|
||||
return { latest, hasUpdate }
|
||||
}
|
||||
@@ -87,6 +87,11 @@ export interface NodeData extends Record<string, unknown> {
|
||||
|
||||
export type EdgePathStyle = 'bezier' | 'smooth'
|
||||
|
||||
export interface Waypoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface EdgeData extends Record<string, unknown> {
|
||||
type: EdgeType
|
||||
label?: string
|
||||
@@ -95,6 +100,7 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
custom_color?: string
|
||||
path_style?: EdgePathStyle
|
||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||
waypoints?: Waypoint[]
|
||||
}
|
||||
|
||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
|
||||
@@ -236,6 +236,36 @@ describe('serializeEdge', () => {
|
||||
expect(result.custom_color).toBeNull()
|
||||
expect(result.path_style).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes waypoints when present', () => {
|
||||
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [{ x: 10, y: 20 }, { x: 30, y: 40 }] } })
|
||||
const result = serializeEdge(edge)
|
||||
expect(result.waypoints).toEqual([{ x: 10, y: 20 }, { x: 30, y: 40 }])
|
||||
})
|
||||
|
||||
it('serializes waypoints as null when empty array', () => {
|
||||
const edge = makeRfEdge({ data: { type: 'ethernet', waypoints: [] } })
|
||||
const result = serializeEdge(edge)
|
||||
expect(result.waypoints).toBeNull()
|
||||
})
|
||||
|
||||
it('serializes waypoints as null when absent', () => {
|
||||
const result = serializeEdge(makeRfEdge())
|
||||
expect(result.waypoints).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deserializeApiEdge — waypoints', () => {
|
||||
it('restores waypoints from API edge', () => {
|
||||
const edge = makeApiEdge({ waypoints: [{ x: 5, y: 15 }, { x: 25, y: 35 }] })
|
||||
const result = deserializeApiEdge(edge)
|
||||
expect((result.data as { waypoints: unknown }).waypoints).toEqual([{ x: 5, y: 15 }, { x: 25, y: 35 }])
|
||||
})
|
||||
|
||||
it('has no waypoints when API edge has none', () => {
|
||||
const result = deserializeApiEdge(makeApiEdge())
|
||||
expect((result.data as { waypoints?: unknown }).waypoints).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ── deserializeApiNode — regular nodes ───────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||
import { normalizeHandle } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -46,6 +46,7 @@ export interface ApiEdge {
|
||||
animated?: boolean | 'snake' | 'flow' | 'none'
|
||||
source_handle?: string | null
|
||||
target_handle?: string | null
|
||||
waypoints?: Waypoint[] | null
|
||||
}
|
||||
|
||||
// ── Serialization (RF node → API save payload) ───────────────────────────────
|
||||
@@ -121,6 +122,7 @@ export function serializeEdge(e: Edge<EdgeData>): Record<string, unknown> {
|
||||
animated: e.data?.animated ?? false,
|
||||
source_handle: normalizeHandle(e.sourceHandle),
|
||||
target_handle: normalizeHandle(e.targetHandle),
|
||||
waypoints: e.data?.waypoints?.length ? e.data.waypoints : null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -2,8 +2,12 @@ import path from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import pkg from './package.json'
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Homelable — Proxmox VE LXC creator
|
||||
# Run this on the Proxmox HOST (not inside a container):
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[homelable]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[homelable]${NC} $*"; }
|
||||
error() { echo -e "${RED}[homelable]${NC} $*"; exit 1; }
|
||||
step() { echo -e "\n${CYAN}▶ $*${NC}"; }
|
||||
|
||||
# ── Must run on a Proxmox VE host ─────────────────────────────────────────────
|
||||
[[ $EUID -ne 0 ]] && error "Run as root on the Proxmox host"
|
||||
command -v pct &>/dev/null || error "pct not found — run this on a Proxmox VE host, not inside a container"
|
||||
|
||||
# ── Detect available storages for LXC rootfs ──────────────────────────────────
|
||||
mapfile -t STORAGES < <(pvesm status --content rootdir 2>/dev/null | awk 'NR>1 && $3=="active" {print $1}')
|
||||
[[ ${#STORAGES[@]} -eq 0 ]] && error "No active storage found that supports LXC rootfs (rootdir content type)"
|
||||
|
||||
if [[ ${#STORAGES[@]} -eq 1 ]]; then
|
||||
DEFAULT_STORAGE="${STORAGES[0]}"
|
||||
else
|
||||
echo ""
|
||||
echo "Available storages:"
|
||||
for i in "${!STORAGES[@]}"; do
|
||||
echo " $((i+1))) ${STORAGES[$i]}"
|
||||
done
|
||||
read -rp "Select storage [1]: " STORAGE_IDX
|
||||
STORAGE_IDX="${STORAGE_IDX:-1}"
|
||||
DEFAULT_STORAGE="${STORAGES[$((STORAGE_IDX-1))]}"
|
||||
fi
|
||||
|
||||
# ── Settings (override via env vars) ──────────────────────────────────────────
|
||||
CT_HOSTNAME="${CT_HOSTNAME:-homelable}"
|
||||
STORAGE="${STORAGE:-$DEFAULT_STORAGE}"
|
||||
DISK_SIZE="${DISK_SIZE:-8}" # GB
|
||||
RAM="${RAM:-1024}" # MB
|
||||
CORES="${CORES:-2}"
|
||||
BRIDGE="${BRIDGE:-vmbr0}"
|
||||
RAW="https://raw.githubusercontent.com/Pouzor/homelable/main"
|
||||
|
||||
# ── Interactive prompts ────────────────────────────────────────────────────────
|
||||
DEFAULT_CTID="$(pvesh get /cluster/nextid 2>/dev/null || echo 200)"
|
||||
|
||||
if [[ -z "${CTID:-}" ]]; then
|
||||
read -rp "Container ID [${DEFAULT_CTID}]: " CTID_INPUT
|
||||
CTID="${CTID_INPUT:-$DEFAULT_CTID}"
|
||||
fi
|
||||
|
||||
if [[ -z "${ROOT_PASSWORD:-}" ]]; then
|
||||
while true; do
|
||||
read -rsp "Root password for LXC container: " ROOT_PASSWORD
|
||||
echo ""
|
||||
[[ -z "$ROOT_PASSWORD" ]] && warn "Password cannot be empty, try again." && continue
|
||||
read -rsp "Confirm root password: " ROOT_PASSWORD_CONFIRM
|
||||
echo ""
|
||||
[[ "$ROOT_PASSWORD" == "$ROOT_PASSWORD_CONFIRM" ]] && break
|
||||
warn "Passwords do not match, try again."
|
||||
done
|
||||
fi
|
||||
|
||||
step "Creating Homelable LXC (CTID=$CTID, hostname=$CT_HOSTNAME, storage=$STORAGE)"
|
||||
|
||||
# ── Download Debian 12 template if needed ─────────────────────────────────────
|
||||
TEMPLATE_STORAGE=$(pvesm status --content vztmpl | awk 'NR>1 {print $1; exit}')
|
||||
TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep "debian-12" | tail -1 | awk '{print $1}')
|
||||
|
||||
if [[ -z "$TEMPLATE" ]]; then
|
||||
info "Downloading Debian 12 LXC template..."
|
||||
pveam update
|
||||
TEMPLATE_NAME=$(pveam available --section system | grep "debian-12" | tail -1 | awk '{print $2}')
|
||||
[[ -z "$TEMPLATE_NAME" ]] && error "Could not find a Debian 12 template"
|
||||
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE_NAME"
|
||||
TEMPLATE="$TEMPLATE_STORAGE:vztmpl/$TEMPLATE_NAME"
|
||||
fi
|
||||
|
||||
info "Using template: $TEMPLATE"
|
||||
|
||||
# ── Create the container ───────────────────────────────────────────────────────
|
||||
pct create "$CTID" "$TEMPLATE" \
|
||||
--hostname "$CT_HOSTNAME" \
|
||||
--storage "$STORAGE" \
|
||||
--rootfs "${STORAGE}:${DISK_SIZE}" \
|
||||
--memory "$RAM" \
|
||||
--cores "$CORES" \
|
||||
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp${VLAN_TAG:+,tag=${VLAN_TAG}}" \
|
||||
--ostype debian \
|
||||
--unprivileged 1 \
|
||||
--features "nesting=1" \
|
||||
--password "$ROOT_PASSWORD" \
|
||||
--start 1
|
||||
|
||||
info "Container $CTID created and started"
|
||||
|
||||
# ── Wait for container to be ready ────────────────────────────────────────────
|
||||
info "Waiting for container to be ready..."
|
||||
for i in $(seq 1 30); do
|
||||
if pct exec "$CTID" -- test -x /usr/bin/apt-get &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
[[ $i -eq 30 ]] && error "Container did not become ready after 30s"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Wait a bit more for network (DHCP lease)
|
||||
info "Waiting for network (DHCP)..."
|
||||
for i in $(seq 1 20); do
|
||||
if pct exec "$CTID" -- sh -c "ip route | grep -q default" &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
[[ $i -eq 20 ]] && error "Container has no default route after 20s — check bridge $BRIDGE"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Grant NET_RAW for nmap (ping-based checks) ─────────────────────────────────
|
||||
echo "lxc.cap.keep = net_raw net_bind_service" >> "/etc/pve/lxc/${CTID}.conf" 2>/dev/null || true
|
||||
|
||||
# ── Bootstrap curl then run the installer ─────────────────────────────────────
|
||||
step "Running Homelable installer inside container $CTID..."
|
||||
pct exec "$CTID" -- apt-get install -y -qq curl
|
||||
pct exec "$CTID" -- bash -c "curl -fsSL ${RAW}/scripts/lxc-install.sh | bash"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}' || echo "<container-ip>")
|
||||
echo ""
|
||||
echo -e " ${GREEN}✓ Homelable installed in LXC $CTID${NC}"
|
||||
echo -e " ${GREEN}✓ Open http://${IP}${NC}"
|
||||
echo -e " Homelable login: ${YELLOW}admin / admin${NC}"
|
||||
echo -e " LXC root SSH: ${YELLOW}root / <password you set>${NC}"
|
||||
echo -e " ${YELLOW}⚠ Change the Homelable password after first login${NC}"
|
||||
echo -e " ${YELLOW} - edit /opt/homelable/backend/.env (AUTH_PASSWORD_HASH)${NC}"
|
||||
echo ""
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Homelable — in-container installer
|
||||
# Runs INSIDE a Debian/Ubuntu LXC container (called automatically by install-proxmox.sh)
|
||||
# Can also be run manually inside any Debian/Ubuntu machine:
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR=/opt/homelable
|
||||
DATA_DIR=/opt/homelable/data
|
||||
SERVICE_USER=homelable
|
||||
REPO_URL="https://github.com/Pouzor/homelable.git"
|
||||
|
||||
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
|
||||
# shellcheck source=/dev/null
|
||||
. /etc/os-release
|
||||
else
|
||||
error "Cannot detect OS"
|
||||
fi
|
||||
info "Detected: $PRETTY_NAME"
|
||||
[[ "$ID" =~ ^(debian|ubuntu)$ ]] || error "Requires Debian or Ubuntu"
|
||||
|
||||
# ── System deps ───────────────────────────────────────────────────────────────
|
||||
info "Installing system dependencies..."
|
||||
apt-get update
|
||||
apt-get install -y --fix-missing python3 python3-pip python3-venv nmap curl git nginx
|
||||
|
||||
# ── Node.js 20 ────────────────────────────────────────────────────────────────
|
||||
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 service user: $SERVICE_USER"
|
||||
fi
|
||||
|
||||
# ── Clone / update repo ───────────────────────────────────────────────────────
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
info "Updating existing installation..."
|
||||
git -C "$INSTALL_DIR" pull --quiet
|
||||
else
|
||||
info "Cloning repository..."
|
||||
git clone --quiet "$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 .env if missing
|
||||
if [[ ! -f .env ]]; then
|
||||
SECRET=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
|
||||
# Default hash = bcrypt of "admin" (same as .env.example)
|
||||
cat > .env <<EOF
|
||||
SECRET_KEY=$SECRET
|
||||
SQLITE_PATH=$DATA_DIR/homelab.db
|
||||
CORS_ORIGINS=["http://localhost","http://$(hostname -I | awk '{print $1}')"]
|
||||
|
||||
# Auth — default credentials: admin / admin
|
||||
# Change AUTH_PASSWORD_HASH before exposing on a network.
|
||||
# Generate: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH='\$2b\$12\$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
||||
|
||||
SCANNER_RANGES=["192.168.1.0/24"]
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
EOF
|
||||
warn "Created .env with default admin/admin — change AUTH_PASSWORD_HASH before exposing on a network!"
|
||||
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
|
||||
npm run build
|
||||
|
||||
# ── nginx ─────────────────────────────────────────────────────────────────────
|
||||
info "Configuring nginx..."
|
||||
# Use the project nginx config, adjusted for local backend
|
||||
sed \
|
||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
||||
"$INSTALL_DIR/docker/nginx.conf" > /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 || systemctl start 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: edit $INSTALL_DIR/backend/.env (AUTH_PASSWORD_HASH)${NC}"
|
||||
echo ""
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Homelable — update to latest version
|
||||
# Run inside the LXC / any Linux host where lxc-install.sh was used:
|
||||
# bash /opt/homelable/scripts/update.sh
|
||||
# Or pull-and-run directly:
|
||||
# bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR=/opt/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 ...)"
|
||||
[[ -d "$INSTALL_DIR/.git" ]] || error "Homelable not found at $INSTALL_DIR — run lxc-install.sh first"
|
||||
|
||||
# ── Pull latest code ──────────────────────────────────────────────────────────
|
||||
info "Pulling latest code..."
|
||||
BEFORE=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||
git -C "$INSTALL_DIR" pull --quiet
|
||||
AFTER=$(git -C "$INSTALL_DIR" rev-parse HEAD)
|
||||
|
||||
if [[ "$BEFORE" == "$AFTER" ]]; then
|
||||
info "Already up to date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
info "Changes since last update:"
|
||||
git -C "$INSTALL_DIR" log --oneline "${BEFORE}..${AFTER}"
|
||||
echo ""
|
||||
|
||||
# ── Stop backend ─────────────────────────────────────────────────────────────
|
||||
info "Stopping backend service..."
|
||||
systemctl stop homelable-backend
|
||||
|
||||
# ── Backend deps ─────────────────────────────────────────────────────────────
|
||||
info "Updating Python dependencies..."
|
||||
cd "$INSTALL_DIR/backend"
|
||||
.venv/bin/pip install --quiet -r requirements.txt
|
||||
|
||||
# ── Frontend build ────────────────────────────────────────────────────────────
|
||||
info "Rebuilding frontend..."
|
||||
cd "$INSTALL_DIR/frontend"
|
||||
npm ci --silent
|
||||
npm run build
|
||||
|
||||
# ── nginx config ─────────────────────────────────────────────────────────────
|
||||
info "Updating nginx config..."
|
||||
sed \
|
||||
-e 's|http://backend:8000|http://127.0.0.1:8000|g' \
|
||||
-e "s|/usr/share/nginx/html|$INSTALL_DIR/frontend/dist|g" \
|
||||
"$INSTALL_DIR/docker/nginx.conf" > /etc/nginx/sites-available/homelable
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
# ── Restart backend ───────────────────────────────────────────────────────────
|
||||
info "Starting backend service..."
|
||||
systemctl start homelable-backend
|
||||
|
||||
echo ""
|
||||
echo -e " ${GREEN}Homelable updated successfully!${NC}"
|
||||
echo -e " Running at http://$(hostname -I | awk '{print $1}')"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user