Merge branch 'Pouzor:main' into fix/zone-styling
This commit is contained in:
@@ -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']
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
||||
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
|
||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||
bottomHandlePositions: () => [50],
|
||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||
}))
|
||||
|
||||
beforeEach(() => { mockZoom = 1 })
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
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>> {
|
||||
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`
|
||||
: 'none',
|
||||
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,
|
||||
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) => {
|
||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||
const sourceId = bottomHandleId(idx)
|
||||
const targetId = `${sourceId}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
<Handle
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
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'
|
||||
import { BaseNode } from './BaseNode'
|
||||
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 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 ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||
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 { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
@@ -6,6 +6,7 @@ import { GroupNode } from './GroupNode'
|
||||
export const nodeTypes = {
|
||||
isp: IspNode,
|
||||
router: RouterNode,
|
||||
firewall: FirewallNode,
|
||||
switch: SwitchNode,
|
||||
server: ServerNode,
|
||||
proxmox: ProxmoxGroupNode,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
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,
|
||||
} from 'lucide-react'
|
||||
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) ───────────────
|
||||
|
||||
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',
|
||||
'docker_container', 'generic',
|
||||
]
|
||||
@@ -27,7 +27,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
|
||||
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,
|
||||
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||
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 { resolveNodeColors } from '@/utils/nodeColors'
|
||||
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[] }[] = [
|
||||
{ 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: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ 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) */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<Select
|
||||
value={String(form.bottom_handles ?? 1)}
|
||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
||||
>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Bottom connection points selector">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_BOTTOM_HANDLES}
|
||||
max={MAX_BOTTOM_HANDLES}
|
||||
step={1}
|
||||
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||
aria-label="Bottom connection points slider"
|
||||
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -404,7 +408,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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 }}
|
||||
>
|
||||
Delete
|
||||
|
||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
it('blocks submit and shows error when label is empty', () => {
|
||||
@@ -345,18 +367,37 @@ describe('NodeModal', () => {
|
||||
|
||||
it('defaults bottom_handles to 1', () => {
|
||||
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', () => {
|
||||
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', () => {
|
||||
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' }))
|
||||
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) {
|
||||
const [_collapsed, setCollapsed] = useState(false)
|
||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
|
||||
// When forceView is set, override local state without useEffect
|
||||
const collapsed = forceView ? false : _collapsed
|
||||
const activeView = forceView ?? _activeView
|
||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||
if (forceView !== prevForceView) {
|
||||
setPrevForceView(forceView)
|
||||
if (forceView) {
|
||||
setActiveView(forceView)
|
||||
setCollapsed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||
|
||||
|
||||
@@ -267,6 +267,18 @@ describe('Sidebar', () => {
|
||||
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 () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
|
||||
@@ -721,6 +721,24 @@ describe('canvasStore', () => {
|
||||
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
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', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
|
||||
|
||||
describe('NODE_TYPE_LABELS', () => {
|
||||
it('has an entry for every node type', () => {
|
||||
const expectedTypes = ['isp', 'router', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||
expectedTypes.forEach((t) => {
|
||||
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
||||
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type NodeType =
|
||||
| 'isp'
|
||||
| 'router'
|
||||
| 'firewall'
|
||||
| 'switch'
|
||||
| 'server'
|
||||
| 'proxmox'
|
||||
@@ -92,6 +93,7 @@ export interface NodeData extends Record<string, unknown> {
|
||||
height?: number
|
||||
}
|
||||
custom_icon?: string
|
||||
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||
bottom_handles?: number
|
||||
}
|
||||
|
||||
@@ -116,6 +118,7 @@ export interface EdgeData extends Record<string, unknown> {
|
||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
isp: 'ISP / Modem',
|
||||
router: 'Router',
|
||||
firewall: 'Firewall',
|
||||
switch: 'Switch',
|
||||
server: 'Server',
|
||||
proxmox: 'Proxmox VE',
|
||||
|
||||
@@ -1,51 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
BOTTOM_HANDLE_IDS,
|
||||
BOTTOM_HANDLE_POSITIONS,
|
||||
MIN_BOTTOM_HANDLES,
|
||||
MAX_BOTTOM_HANDLES,
|
||||
bottomHandleId,
|
||||
bottomHandlePositions,
|
||||
clampBottomHandles,
|
||||
normalizeHandle,
|
||||
removedBottomHandleIds,
|
||||
} from '../handleUtils'
|
||||
|
||||
describe('BOTTOM_HANDLE_IDS', () => {
|
||||
describe('bottomHandleId', () => {
|
||||
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', () => {
|
||||
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
|
||||
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
|
||||
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
|
||||
expect(bottomHandleId(1)).toBe('bottom-2')
|
||||
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%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
|
||||
expect(bottomHandlePositions(1)).toEqual([50])
|
||||
})
|
||||
|
||||
it('2 handles are symmetric', () => {
|
||||
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
|
||||
expect(a).toBeLessThan(50)
|
||||
expect(b).toBeGreaterThan(50)
|
||||
expect(a + b).toBe(100)
|
||||
it('2 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(2)).toEqual([25, 75])
|
||||
})
|
||||
|
||||
it('3 handles include a center at 50%', () => {
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
|
||||
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
|
||||
it('3 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
|
||||
})
|
||||
|
||||
it('4 handles are evenly spaced', () => {
|
||||
const pos = BOTTOM_HANDLE_POSITIONS[4]
|
||||
expect(pos).toHaveLength(4)
|
||||
// All values should be between 0 and 100 exclusive
|
||||
it('4 handles use exact prior positions', () => {
|
||||
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
|
||||
})
|
||||
})
|
||||
|
||||
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) => {
|
||||
expect(p).toBeGreaterThan(0)
|
||||
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++) {
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
|
||||
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
|
||||
})
|
||||
|
||||
it('passes through non-stub handles unchanged', () => {
|
||||
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
|
||||
})
|
||||
|
||||
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 1)
|
||||
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('4 → 2 removes bottom-3, bottom-4', () => {
|
||||
const removed = removedBottomHandleIds(4, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
|
||||
})
|
||||
|
||||
it('3 → 2 removes only bottom-3', () => {
|
||||
const removed = removedBottomHandleIds(3, 2)
|
||||
expect(removed).toEqual(new Set(['bottom-3']))
|
||||
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
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',
|
||||
]
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||
import { normalizeHandle } from '@/utils/handleUtils'
|
||||
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.measured?.width ?? n.width ?? 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_y: n.position.y,
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export function deserializeApiNode(
|
||||
id: n.id,
|
||||
type: normalizedType,
|
||||
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 } : {}),
|
||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
|
||||
@@ -2,20 +2,44 @@
|
||||
* Bottom handle configuration for multi-handle nodes.
|
||||
*
|
||||
* 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:
|
||||
* '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. */
|
||||
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
|
||||
1: [50],
|
||||
2: [25, 75],
|
||||
3: [20, 50, 80],
|
||||
4: [15, 38, 62, 85],
|
||||
/** Returns the source handle ID at a given slot index. */
|
||||
export function bottomHandleId(idx: number): string {
|
||||
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const removed = new Set<string>()
|
||||
for (let i = newCount; i < oldCount; i++) {
|
||||
removed.add(BOTTOM_HANDLE_IDS[i])
|
||||
removed.add(bottomHandleId(i))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
// Storage & Databases
|
||||
Database, Archive, Cloud, FolderOpen,
|
||||
// Security & Auth
|
||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck,
|
||||
Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame,
|
||||
// Automation & IoT
|
||||
Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio,
|
||||
// Transfers & sync
|
||||
@@ -34,6 +34,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
||||
// --- Infrastructure ---
|
||||
{ key: 'globe', label: 'Globe / ISP', category: 'Infrastructure', icon: Globe },
|
||||
{ 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: 'server', label: 'Server', category: 'Infrastructure', icon: Server },
|
||||
{ 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> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
firewall: Flame,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
|
||||
@@ -44,6 +44,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
firewall: { border: '#f85149', icon: '#f85149' },
|
||||
switch: { border: '#39d353', icon: '#39d353' },
|
||||
server: { border: '#a855f7', icon: '#a855f7' },
|
||||
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
@@ -100,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
nodeAccents: {
|
||||
isp: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
router: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
firewall: { border: '#ef4444', icon: '#ef4444' },
|
||||
switch: { border: '#4ade80', icon: '#4ade80' },
|
||||
server: { border: '#c084fc', icon: '#c084fc' },
|
||||
proxmox: { border: '#fb923c', icon: '#fb923c' },
|
||||
@@ -156,6 +158,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
nodeAccents: {
|
||||
isp: { border: '#0284c7', icon: '#0284c7' },
|
||||
router: { border: '#0284c7', icon: '#0284c7' },
|
||||
firewall: { border: '#dc2626', icon: '#dc2626' },
|
||||
switch: { border: '#16a34a', icon: '#16a34a' },
|
||||
server: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
proxmox: { border: '#ea580c', icon: '#ea580c' },
|
||||
@@ -212,6 +215,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00ffff', icon: '#00ffff' },
|
||||
router: { border: '#00ffff', icon: '#00ffff' },
|
||||
firewall: { border: '#ff0040', icon: '#ff0040' },
|
||||
switch: { border: '#00ff80', icon: '#00ff80' },
|
||||
server: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
proxmox: { border: '#ff8800', icon: '#ff8800' },
|
||||
@@ -268,6 +272,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00ff41', icon: '#00ff41' },
|
||||
router: { border: '#00ff41', icon: '#00ff41' },
|
||||
firewall: { border: '#88ff00', icon: '#88ff00' },
|
||||
switch: { border: '#00cc33', icon: '#00cc33' },
|
||||
server: { border: '#008822', icon: '#008822' },
|
||||
proxmox: { border: '#33ff66', icon: '#33ff66' },
|
||||
@@ -324,6 +329,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
nodeAccents: {
|
||||
isp: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
router: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
firewall: { border: '#f85149', icon: '#f85149' },
|
||||
switch: { border: '#39d353', icon: '#39d353' },
|
||||
server: { border: '#a855f7', icon: '#a855f7' },
|
||||
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
|
||||
Reference in New Issue
Block a user