Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fb77ab00b | |||
| 56cfbd1e76 | |||
| 43761c60cb | |||
| ad958feabd | |||
| e3876e934c | |||
| dfa4a9c849 | |||
| 785be6a5dd | |||
| 39f8d16ef1 | |||
| 0095bf8425 |
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: pouzor
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -99,7 +99,7 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optionnal)
|
## MCP Server (AI Integration) (optional)
|
||||||
|
|
||||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.12.0",
|
"version": "1.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
bottomHandlePositions: () => [50],
|
||||||
|
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
beforeEach(() => { mockZoom = 1 })
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -56,7 +56,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
||||||
|
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
||||||
width: width ? '100%' : undefined,
|
width: width ? '100%' : undefined,
|
||||||
height: height ? '100%' : undefined,
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -173,9 +174,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
const sourceId = bottomHandleId(idx)
|
||||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
const targetId = `${sourceId}-t`
|
||||||
return (
|
return (
|
||||||
<span key={sourceId}>
|
<span key={sourceId}>
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -10,6 +10,7 @@ type N = NodeProps<Node<NodeData>>
|
|||||||
|
|
||||||
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
||||||
|
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
|
||||||
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
@@ -6,6 +6,7 @@ import { GroupNode } from './GroupNode'
|
|||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
router: RouterNode,
|
router: RouterNode,
|
||||||
|
firewall: FirewallNode,
|
||||||
switch: SwitchNode,
|
switch: SwitchNode,
|
||||||
server: ServerNode,
|
server: ServerNode,
|
||||||
proxmox: ProxmoxGroupNode,
|
proxmox: ProxmoxGroupNode,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||||
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle,
|
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
@@ -19,7 +19,7 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
|||||||
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
|
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
|
||||||
|
|
||||||
const EDITABLE_NODE_TYPES: NodeType[] = [
|
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||||
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
||||||
'docker_container', 'generic',
|
'docker_container', 'generic',
|
||||||
]
|
]
|
||||||
@@ -27,7 +27,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
|
|||||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
|
|
||||||
const NODE_ICONS: Record<string, LucideIcon> = {
|
const NODE_ICONS: Record<string, LucideIcon> = {
|
||||||
isp: Globe, router: Router, switch: Network, server: Server, proxmox: Layers,
|
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||||
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
||||||
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||||
docker_host: Anchor, docker_container: Package, generic: Circle,
|
docker_host: Anchor, docker_container: Package, generic: Circle,
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
|
|||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
||||||
|
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||||
@@ -366,21 +367,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Bottom connection points (not for group containers) */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Select
|
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||||
value={String(form.bottom_handles ?? 1)}
|
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
</div>
|
||||||
>
|
<input
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Bottom connection points selector">
|
type="range"
|
||||||
<SelectValue />
|
min={MIN_BOTTOM_HANDLES}
|
||||||
</SelectTrigger>
|
max={MAX_BOTTOM_HANDLES}
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
step={1}
|
||||||
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||||
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||||
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
aria-label="Bottom connection points slider"
|
||||||
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
||||||
|
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||||
|
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -404,7 +408,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
onClick={() => { if (window.confirm('Delete this node?')) onSubmit({ ...form, _delete: true }); onClose(); }}
|
onClick={() => {
|
||||||
|
if (window.confirm('Delete this node?')) {
|
||||||
|
onSubmit({ ...form, _delete: true })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{ minWidth: 64 }}
|
style={{ minWidth: 64 }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Delete confirm ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('deletes and closes when Delete confirm is accepted', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: bare-if without braces used to call onClose() unconditionally,
|
||||||
|
// closing the modal even when the user cancelled the confirm dialog.
|
||||||
|
it('does not delete or close when Delete confirm is cancelled', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Label validation ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
@@ -345,18 +367,37 @@ describe('NodeModal', () => {
|
|||||||
|
|
||||||
it('defaults bottom_handles to 1', () => {
|
it('defaults bottom_handles to 1', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect(selects()[2].value).toBe('1')
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills bottom_handles from initial', () => {
|
it('pre-fills bottom_handles from initial', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||||
expect(selects()[2].value).toBe('3')
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits updated bottom_handles', () => {
|
it('submits updated bottom_handles', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(selects()[2], { target: { value: '4' } })
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
fireEvent.change(slider, { target: { value: '12' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the full 1..48 range', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.min).toBe('1')
|
||||||
|
expect(slider.max).toBe('48')
|
||||||
|
fireEvent.change(slider, { target: { value: '48' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps pre-filled out-of-range values into [1,48]', () => {
|
||||||
|
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||||
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('48')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,13 +43,19 @@ interface SidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
||||||
const [_collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
|
||||||
// When forceView is set, override local state without useEffect
|
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||||
const collapsed = forceView ? false : _collapsed
|
if (forceView !== prevForceView) {
|
||||||
const activeView = forceView ?? _activeView
|
setPrevForceView(forceView)
|
||||||
|
if (forceView) {
|
||||||
|
setActiveView(forceView)
|
||||||
|
setCollapsed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,18 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: forceView used to override local state on every render, freezing
|
||||||
|
// the sidebar on whichever view the parent forced (e.g. 'history' after a scan).
|
||||||
|
it('allows switching views after forceView is set by parent', async () => {
|
||||||
|
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
|
// Parent keeps forceView as 'history'; user clicks another nav item.
|
||||||
|
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
|
fireEvent.click(screen.getByText('Pending Devices'))
|
||||||
|
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
||||||
|
expect(screen.queryByText('No scans yet')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('toggles Settings panel on Settings click', async () => {
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Settings'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
|
|||||||
@@ -721,6 +721,24 @@ describe('canvasStore', () => {
|
|||||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||||
expect(updated?.sourceHandle).toBe('bottom')
|
expect(updated?.sourceHandle).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: handle cap raised from 4 to 48 — remap must scale.
|
||||||
|
it('remaps high-count handles when shrinking from 12 to 2', () => {
|
||||||
|
const node = makeNode('n1', { bottom_handles: 12 })
|
||||||
|
const edges = [
|
||||||
|
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' },
|
||||||
|
{ ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' },
|
||||||
|
{ ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' },
|
||||||
|
]
|
||||||
|
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
|
||||||
|
|
||||||
|
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
|
||||||
|
|
||||||
|
const after = useCanvasStore.getState().edges
|
||||||
|
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2')
|
||||||
|
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
|
||||||
|
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('canvasStore — custom style apply', () => {
|
describe('canvasStore — custom style apply', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
|
|||||||
|
|
||||||
describe('NODE_TYPE_LABELS', () => {
|
describe('NODE_TYPE_LABELS', () => {
|
||||||
it('has an entry for every node type', () => {
|
it('has an entry for every node type', () => {
|
||||||
const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||||
expectedTypes.forEach((t) => {
|
expectedTypes.forEach((t) => {
|
||||||
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
||||||
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type NodeType =
|
export type NodeType =
|
||||||
| 'isp'
|
| 'isp'
|
||||||
| 'router'
|
| 'router'
|
||||||
|
| 'firewall'
|
||||||
| 'switch'
|
| 'switch'
|
||||||
| 'server'
|
| 'server'
|
||||||
| 'proxmox'
|
| 'proxmox'
|
||||||
@@ -92,6 +93,7 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
height?: number
|
height?: number
|
||||||
}
|
}
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
|
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||||
bottom_handles?: number
|
bottom_handles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +118,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||||
isp: 'ISP / Modem',
|
isp: 'ISP / Modem',
|
||||||
router: 'Router',
|
router: 'Router',
|
||||||
|
firewall: 'Firewall',
|
||||||
switch: 'Switch',
|
switch: 'Switch',
|
||||||
server: 'Server',
|
server: 'Server',
|
||||||
proxmox: 'Proxmox VE',
|
proxmox: 'Proxmox VE',
|
||||||
|
|||||||
@@ -1,51 +1,105 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import {
|
import {
|
||||||
BOTTOM_HANDLE_IDS,
|
MIN_BOTTOM_HANDLES,
|
||||||
BOTTOM_HANDLE_POSITIONS,
|
MAX_BOTTOM_HANDLES,
|
||||||
|
bottomHandleId,
|
||||||
|
bottomHandlePositions,
|
||||||
|
clampBottomHandles,
|
||||||
normalizeHandle,
|
normalizeHandle,
|
||||||
removedBottomHandleIds,
|
removedBottomHandleIds,
|
||||||
} from '../handleUtils'
|
} from '../handleUtils'
|
||||||
|
|
||||||
describe('BOTTOM_HANDLE_IDS', () => {
|
describe('bottomHandleId', () => {
|
||||||
it('first id is always "bottom" for backward compatibility', () => {
|
it('first id is always "bottom" for backward compatibility', () => {
|
||||||
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
|
expect(bottomHandleId(0)).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('has ids for 1–4 handles', () => {
|
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
|
||||||
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
expect(bottomHandleId(1)).toBe('bottom-2')
|
||||||
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
expect(bottomHandleId(2)).toBe('bottom-3')
|
||||||
|
expect(bottomHandleId(3)).toBe('bottom-4')
|
||||||
|
expect(bottomHandleId(11)).toBe('bottom-12')
|
||||||
|
expect(bottomHandleId(47)).toBe('bottom-48')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('BOTTOM_HANDLE_POSITIONS', () => {
|
describe('clampBottomHandles', () => {
|
||||||
|
it('clamps below MIN to MIN', () => {
|
||||||
|
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps above MAX to MAX', () => {
|
||||||
|
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns MIN for non-finite or non-number', () => {
|
||||||
|
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('floors fractional values', () => {
|
||||||
|
expect(clampBottomHandles(3.9)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes valid integers through', () => {
|
||||||
|
expect(clampBottomHandles(1)).toBe(1)
|
||||||
|
expect(clampBottomHandles(24)).toBe(24)
|
||||||
|
expect(clampBottomHandles(48)).toBe(48)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bottomHandlePositions — backward-compat lock for 1..4', () => {
|
||||||
|
// These exact arrays are the pre-multi-handle hand-tuned positions.
|
||||||
|
// Existing user canvases depend on them — do NOT change.
|
||||||
it('1 handle is centered at 50%', () => {
|
it('1 handle is centered at 50%', () => {
|
||||||
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
expect(bottomHandlePositions(1)).toEqual([50])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('2 handles are symmetric', () => {
|
it('2 handles use exact prior positions', () => {
|
||||||
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
expect(bottomHandlePositions(2)).toEqual([25, 75])
|
||||||
expect(a).toBeLessThan(50)
|
|
||||||
expect(b).toBeGreaterThan(50)
|
|
||||||
expect(a + b).toBe(100)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('3 handles include a center at 50%', () => {
|
it('3 handles use exact prior positions', () => {
|
||||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
|
||||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('4 handles are evenly spaced', () => {
|
it('4 handles use exact prior positions', () => {
|
||||||
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
|
||||||
expect(pos).toHaveLength(4)
|
})
|
||||||
// All values should be between 0 and 100 exclusive
|
})
|
||||||
|
|
||||||
|
describe('bottomHandlePositions — uniform spacing for ≥5', () => {
|
||||||
|
it('returns count entries', () => {
|
||||||
|
expect(bottomHandlePositions(5)).toHaveLength(5)
|
||||||
|
expect(bottomHandlePositions(12)).toHaveLength(12)
|
||||||
|
expect(bottomHandlePositions(48)).toHaveLength(48)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all positions strictly between 0 and 100', () => {
|
||||||
|
const pos = bottomHandlePositions(48)
|
||||||
pos.forEach((p) => {
|
pos.forEach((p) => {
|
||||||
expect(p).toBeGreaterThan(0)
|
expect(p).toBeGreaterThan(0)
|
||||||
expect(p).toBeLessThan(100)
|
expect(p).toBeLessThan(100)
|
||||||
})
|
})
|
||||||
// Positions should be strictly increasing
|
})
|
||||||
|
|
||||||
|
it('positions are strictly increasing and uniform', () => {
|
||||||
|
const pos = bottomHandlePositions(12)
|
||||||
for (let i = 1; i < pos.length; i++) {
|
for (let i = 1; i < pos.length; i++) {
|
||||||
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
expect(pos[i]).toBeGreaterThan(pos[i - 1])
|
||||||
}
|
}
|
||||||
|
const step = 100 / 13
|
||||||
|
expect(pos[0]).toBeCloseTo(step, 5)
|
||||||
|
expect(pos[11]).toBeCloseTo(step * 12, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps out-of-range counts before computing', () => {
|
||||||
|
expect(bottomHandlePositions(0)).toEqual([50])
|
||||||
|
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,16 +117,10 @@ describe('normalizeHandle', () => {
|
|||||||
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
expect(normalizeHandle('bottom-t')).toBe('bottom')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps bottom-2-t → bottom-2', () => {
|
it('maps bottom-N-t → bottom-N for any N', () => {
|
||||||
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
|
||||||
})
|
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
|
||||||
|
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
|
||||||
it('maps bottom-3-t → bottom-3', () => {
|
|
||||||
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps bottom-4-t → bottom-4', () => {
|
|
||||||
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes through non-stub handles unchanged', () => {
|
it('passes through non-stub handles unchanged', () => {
|
||||||
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||||
const removed = removedBottomHandleIds(4, 1)
|
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||||
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('4 → 2 removes bottom-3, bottom-4', () => {
|
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||||
const removed = removedBottomHandleIds(4, 2)
|
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||||
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('3 → 2 removes only bottom-3', () => {
|
it('3 → 2 removes only bottom-3', () => {
|
||||||
const removed = removedBottomHandleIds(3, 2)
|
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
|
||||||
expect(removed).toEqual(new Set(['bottom-3']))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('never removes "bottom" (index 0)', () => {
|
it('never removes "bottom" (index 0)', () => {
|
||||||
const removed = removedBottomHandleIds(4, 1)
|
expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: scaling the cap from 4 → 48 must not break the remap loop.
|
||||||
|
it('scales to high counts (48 → 1 removes 47 ids)', () => {
|
||||||
|
const removed = removedBottomHandleIds(48, 1)
|
||||||
|
expect(removed.size).toBe(47)
|
||||||
|
expect(removed.has('bottom-2')).toBe(true)
|
||||||
|
expect(removed.has('bottom-48')).toBe(true)
|
||||||
expect(removed.has('bottom')).toBe(false)
|
expect(removed.has('bottom')).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => {
|
||||||
|
const removed = removedBottomHandleIds(10, 2)
|
||||||
|
expect(removed.size).toBe(8)
|
||||||
|
expect(removed.has('bottom-2')).toBe(false)
|
||||||
|
expect(removed.has('bottom-3')).toBe(true)
|
||||||
|
expect(removed.has('bottom-10')).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { THEMES, THEME_ORDER, type ThemeId } from '../themes'
|
|||||||
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||||
|
|
||||||
const NODE_TYPES: NodeType[] = [
|
const NODE_TYPES: NodeType[] = [
|
||||||
'isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||||
]
|
]
|
||||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Node, Edge } from '@xyflow/react'
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||||
import { normalizeHandle } from '@/utils/handleUtils'
|
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
|||||||
properties: n.data.properties ?? [],
|
properties: n.data.properties ?? [],
|
||||||
width: n.measured?.width ?? n.width ?? null,
|
width: n.measured?.width ?? n.width ?? null,
|
||||||
height: n.measured?.height ?? n.height ?? null,
|
height: n.measured?.height ?? n.height ?? null,
|
||||||
bottom_handles: n.data.bottom_handles ?? 1,
|
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||||
pos_x: n.position.x,
|
pos_x: n.position.x,
|
||||||
pos_y: n.position.y,
|
pos_y: n.position.y,
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ export function deserializeApiNode(
|
|||||||
id: n.id,
|
id: n.id,
|
||||||
type: normalizedType,
|
type: normalizedType,
|
||||||
position: { x: n.pos_x, y: n.pos_y },
|
position: { x: n.pos_x, y: n.pos_y },
|
||||||
data: { ...n, type: normalizedType } as unknown as NodeData,
|
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
|
||||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||||
|
|||||||
@@ -2,20 +2,44 @@
|
|||||||
* Bottom handle configuration for multi-handle nodes.
|
* Bottom handle configuration for multi-handle nodes.
|
||||||
*
|
*
|
||||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||||
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
|
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
||||||
*
|
*
|
||||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||||
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
|
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
|
export const MIN_BOTTOM_HANDLES = 1
|
||||||
|
export const MAX_BOTTOM_HANDLES = 48
|
||||||
|
|
||||||
/** Left % position for each handle slot, per count. */
|
/** Returns the source handle ID at a given slot index. */
|
||||||
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
export function bottomHandleId(idx: number): string {
|
||||||
1: [50],
|
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
||||||
2: [25, 75],
|
}
|
||||||
3: [20, 50, 80],
|
|
||||||
4: [15, 38, 62, 85],
|
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
|
||||||
|
export function clampBottomHandles(n: unknown): number {
|
||||||
|
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
|
||||||
|
const i = Math.floor(n)
|
||||||
|
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
|
||||||
|
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left % positions for each handle slot.
|
||||||
|
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
|
||||||
|
* positions on canvases saved before the multi-handle expansion.
|
||||||
|
* Counts ≥ 5 use uniform spacing.
|
||||||
|
*/
|
||||||
|
export function bottomHandlePositions(count: number): number[] {
|
||||||
|
const c = clampBottomHandles(count)
|
||||||
|
switch (c) {
|
||||||
|
case 1: return [50]
|
||||||
|
case 2: return [25, 75]
|
||||||
|
case 3: return [20, 50, 80]
|
||||||
|
case 4: return [15, 38, 62, 85]
|
||||||
|
default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null {
|
|||||||
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
|
||||||
const removed = new Set<string>()
|
const removed = new Set<string>()
|
||||||
for (let i = newCount; i < oldCount; i++) {
|
for (let i = newCount; i < oldCount; i++) {
|
||||||
removed.add(BOTTOM_HANDLE_IDS[i])
|
removed.add(bottomHandleId(i))
|
||||||
}
|
}
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
// Storage & Databases
|
// Storage & Databases
|
||||||
Database, Archive, Cloud, FolderOpen,
|
Database, Archive, Cloud, FolderOpen,
|
||||||
// Security & Auth
|
// Security & Auth
|
||||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck,
|
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||||
// Automation & IoT
|
// Automation & IoT
|
||||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
||||||
// Transfers & sync
|
// Transfers & sync
|
||||||
@@ -34,6 +34,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
|||||||
// --- Infrastructure ---
|
// --- Infrastructure ---
|
||||||
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
||||||
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
{ key: 'router', label: 'Router', category: 'Infrastructure', icon: Router },
|
||||||
|
{ key: 'flame', label: 'Firewall', category: 'Infrastructure', icon: Flame },
|
||||||
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
{ key: 'network', label: 'Switch / Network', category: 'Infrastructure', icon: Network },
|
||||||
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
{ key: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
||||||
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
|
{ key: 'layers', label: 'Proxmox / Hypervisor', category: 'Infrastructure', icon: Layers },
|
||||||
@@ -120,6 +121,7 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
|||||||
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||||
isp: Globe,
|
isp: Globe,
|
||||||
router: Router,
|
router: Router,
|
||||||
|
firewall: Flame,
|
||||||
switch: Network,
|
switch: Network,
|
||||||
server: Server,
|
server: Server,
|
||||||
proxmox: Layers,
|
proxmox: Layers,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
firewall: { border: '#f85149', icon: '#f85149' },
|
||||||
switch: { border: '#39d353', icon: '#39d353' },
|
switch: { border: '#39d353', icon: '#39d353' },
|
||||||
server: { border: '#a855f7', icon: '#a855f7' },
|
server: { border: '#a855f7', icon: '#a855f7' },
|
||||||
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
@@ -100,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
router: { border: '#22d3ee', icon: '#22d3ee' },
|
router: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
|
firewall: { border: '#ef4444', icon: '#ef4444' },
|
||||||
switch: { border: '#4ade80', icon: '#4ade80' },
|
switch: { border: '#4ade80', icon: '#4ade80' },
|
||||||
server: { border: '#c084fc', icon: '#c084fc' },
|
server: { border: '#c084fc', icon: '#c084fc' },
|
||||||
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
||||||
@@ -156,6 +158,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#0284c7', icon: '#0284c7' },
|
isp: { border: '#0284c7', icon: '#0284c7' },
|
||||||
router: { border: '#0284c7', icon: '#0284c7' },
|
router: { border: '#0284c7', icon: '#0284c7' },
|
||||||
|
firewall: { border: '#dc2626', icon: '#dc2626' },
|
||||||
switch: { border: '#16a34a', icon: '#16a34a' },
|
switch: { border: '#16a34a', icon: '#16a34a' },
|
||||||
server: { border: '#7c3aed', icon: '#7c3aed' },
|
server: { border: '#7c3aed', icon: '#7c3aed' },
|
||||||
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
||||||
@@ -212,6 +215,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00ffff', icon: '#00ffff' },
|
isp: { border: '#00ffff', icon: '#00ffff' },
|
||||||
router: { border: '#00ffff', icon: '#00ffff' },
|
router: { border: '#00ffff', icon: '#00ffff' },
|
||||||
|
firewall: { border: '#ff0040', icon: '#ff0040' },
|
||||||
switch: { border: '#00ff80', icon: '#00ff80' },
|
switch: { border: '#00ff80', icon: '#00ff80' },
|
||||||
server: { border: '#ff00ff', icon: '#ff00ff' },
|
server: { border: '#ff00ff', icon: '#ff00ff' },
|
||||||
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
||||||
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00ff41', icon: '#00ff41' },
|
isp: { border: '#00ff41', icon: '#00ff41' },
|
||||||
router: { border: '#00ff41', icon: '#00ff41' },
|
router: { border: '#00ff41', icon: '#00ff41' },
|
||||||
|
firewall: { border: '#88ff00', icon: '#88ff00' },
|
||||||
switch: { border: '#00cc33', icon: '#00cc33' },
|
switch: { border: '#00cc33', icon: '#00cc33' },
|
||||||
server: { border: '#008822', icon: '#008822' },
|
server: { border: '#008822', icon: '#008822' },
|
||||||
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
||||||
@@ -324,6 +329,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
nodeAccents: {
|
nodeAccents: {
|
||||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
firewall: { border: '#f85149', icon: '#f85149' },
|
||||||
switch: { border: '#39d353', icon: '#39d353' },
|
switch: { border: '#39d353', icon: '#39d353' },
|
||||||
server: { border: '#a855f7', icon: '#a855f7' },
|
server: { border: '#a855f7', icon: '#a855f7' },
|
||||||
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
|||||||
Reference in New Issue
Block a user