Compare commits

...

8 Commits

Author SHA1 Message Date
Pouzor 15f210470a fix: remove duplicate hideIp property in canvasStore 2026-03-11 16:21:15 +01:00
Pouzor 271bbf2d01 feat: add hide IP toggle in sidebar
- Add hideIp / toggleHideIp to canvasStore (UI-only, not persisted)
- Add maskIp util: 192.168.1.115 → 192.168.XX.XX (non-IPv4 passthrough)
- BaseNode reads hideIp from store and masks last two octets when active
- Sidebar shows Eye/EyeOff toggle below Scan Network, highlights when active
2026-03-11 16:15:22 +01:00
Pouzor 8affcda09d fix: improve auto-layout peer alignment and edge direction
- Exclude peer-to-peer edges from Dagre to keep same-type nodes at same rank
- Snap peer group Y to average, re-space X preserving user's original order
- Reverse upward edges (sourceHandle=top) in Dagre so child nodes always render below parent
- Add layout.test.ts covering peer alignment, X ordering, and upward edge cases
2026-03-11 14:29:29 +01:00
Pouzor 92d505f78c feat: add logo, favicon, and theme system
- Add custom SVG favicon and Logo component (house + network nodes motif)
- Update page title to Homelable with meta description
- Show Logo in sidebar header and toolbar
- Add theme store and ThemeModal for canvas style switching
- Refactor node colors and edge styles for theme support
2026-03-11 14:29:15 +01:00
Pouzor 16de7cd390 fix: accept string | null in Select onValueChange (Base UI type) 2026-03-10 17:40:21 +01:00
Pouzor 0fb091b12c fix: resolve TS build errors for CI
- Add groupRect entry to NODE_DEFAULT_COLORS (required by Record<NodeType, NodeColors>)
- Explicitly type Select onValueChange callbacks as (v: string) to satisfy
  stricter type inference in tsc -b build mode
2026-03-10 17:37:00 +01:00
Pouzor 1f1dfd807b feat: add/remove services in detail panel + fix service URL detection
- Any TCP service (except port 22 and known non-HTTP protocols) is now
  clickable — fixes Sonarr, Radarr, Jellyfin, Proxmox web UI, etc.
- HTTPS auto-detected for port 443/8443 or names containing ssl/tls/https
- Non-HTTP blocklist: FTP, SMTP, DNS, LDAP, SMB, MySQL, Postgres,
  Redis, MongoDB, Kafka, Memcached, etc.
- Inline "Add service" form in detail panel (name, port, tcp/udp)
- Remove button (×) on hover for each service row
- Group rect nodes excluded from detail panel
- getServiceUrl extracted to utils/serviceUrl.ts
- 13 new tests (79 total, all pass)
2026-03-10 17:29:41 +01:00
Pouzor 1e72366d03 feat: add Group Rectangle — resizable decorative zones on canvas
- New GroupRectNode: rounded rect with NodeResizer (8 handles), no
  connection handles, always behind network nodes via negative zIndex
- GroupRectModal: label, font preset (Inter/Mono/Serif), 3×3 text
  position grid, text/border/background color pickers, z-order 1–9
- Sidebar: "Add Rectangle" button (below Add Node), group rects
  excluded from node count stats
- Save/load: size persisted in custom_colors.width/height, no backend
  schema changes required
- elevateNodesOnSelect=false on ReactFlow so selected rects never
  pop above network nodes
- Tests: 3 new store tests + 9 GroupRectModal tests (66 total, all pass)
2026-03-10 16:59:40 +01:00
32 changed files with 2038 additions and 179 deletions
+3 -2
View File
@@ -2,9 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <meta name="description" content="Homelable — Visual homelab infrastructure map with live monitoring" />
<title>Homelable</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Background circle -->
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
<!-- House silhouette -->
<path d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
<!-- Door -->
<rect x="27" y="40" width="10" height="12" rx="1"
fill="#0d1117" stroke="#00d4ff" stroke-width="1"/>
<!-- Network nodes -->
<!-- Center node (hub) -->
<circle cx="32" cy="33" r="3" fill="#00d4ff"/>
<!-- Left node -->
<circle cx="22" cy="38" r="2" fill="#39d353"/>
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
<!-- Right node -->
<circle cx="42" cy="38" r="2" fill="#39d353"/>
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1" opacity="0.7"/>
<!-- Top node (inside roof area) -->
<circle cx="32" cy="24" r="2" fill="#a855f7"/>
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" stroke-width="1" opacity="0.7"/>
<!-- Glow effect on center node -->
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" stroke-width="1.5" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+170 -26
View File
@@ -14,8 +14,11 @@ import { LoginPage } from '@/components/LoginPage'
import { NodeModal } from '@/components/modals/NodeModal' import { NodeModal } from '@/components/modals/NodeModal'
import { EdgeModal } from '@/components/modals/EdgeModal' import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal' import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client' import { canvasApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
@@ -25,13 +28,16 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { export default function App() {
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore() const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme } = useThemeStore()
useStatusPolling() useStatusPolling()
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState<string | null>(null) const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null) const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null) const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
@@ -41,31 +47,60 @@ export default function App() {
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
try { try {
if (STANDALONE) { if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges })) localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return return
} }
const nodesToSave = nodes.map((n) => ({ const nodesToSave = nodes.map((n) => {
id: n.id, if (n.data.type === 'groupRect') {
type: n.data.type, return {
label: n.data.label, id: n.id,
hostname: n.data.hostname ?? null, type: 'groupRect',
ip: n.data.ip ?? null, label: n.data.label,
mac: n.data.mac ?? null, hostname: null,
os: n.data.os ?? null, ip: null,
status: n.data.status, mac: null,
check_method: n.data.check_method ?? null, os: null,
check_target: n.data.check_target ?? null, status: 'unknown',
services: n.data.services ?? [], check_method: null,
notes: n.data.notes ?? null, check_target: null,
parent_id: n.data.parent_id ?? null, services: [],
container_mode: n.data.container_mode ?? false, notes: null,
custom_colors: n.data.custom_colors ?? null, parent_id: null,
custom_icon: n.data.custom_icon ?? null, container_mode: false,
pos_x: n.position.x, custom_icon: null,
pos_y: n.position.y, pos_x: n.position.x,
})) pos_y: n.position.y,
// Persist size and all rect config inside custom_colors
custom_colors: {
...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360,
height: n.measured?.height ?? n.height ?? 240,
},
}
}
return {
id: n.id,
type: n.data.type,
label: n.data.label,
hostname: n.data.hostname ?? null,
ip: n.data.ip ?? null,
mac: n.data.mac ?? null,
os: n.data.os ?? null,
status: n.data.status,
check_method: n.data.check_method ?? null,
check_target: n.data.check_target ?? null,
services: n.data.services ?? [],
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
custom_colors: n.data.custom_colors ?? null,
custom_icon: n.data.custom_icon ?? null,
pos_x: n.position.x,
pos_y: n.position.y,
}
})
const edgesToSave = edges.map((e) => ({ const edgesToSave = edges.map((e) => ({
id: e.id, id: e.id,
source: e.source, source: e.source,
@@ -81,13 +116,13 @@ export default function App() {
source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null), source_handle: e.sourceHandle === 'top-t' ? 'top' : e.sourceHandle === 'bottom-t' ? 'bottom' : (e.sourceHandle ?? null),
target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null), target_handle: e.targetHandle === 'top-t' ? 'top' : e.targetHandle === 'bottom-t' ? 'bottom' : (e.targetHandle ?? null),
})) }))
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: {} }) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
} catch { } catch {
toast.error('Save failed') toast.error('Save failed')
} }
}, [nodes, edges, markSaved]) }, [nodes, edges, markSaved, activeTheme])
// Keep a ref so the keydown handler always calls the latest version // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
@@ -99,7 +134,8 @@ export default function App() {
try { try {
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY) const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) { if (saved) {
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
if (theme_id) setTheme(theme_id)
loadCanvas(savedNodes, savedEdges) loadCanvas(savedNodes, savedEdges)
} else { } else {
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
@@ -121,6 +157,20 @@ export default function App() {
.map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false]) .map((n: NodeData & { id: string }) => [n.id, n.container_mode !== false])
) )
const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => { const rfNodes = apiNodes.map((n: NodeData & { id: string; pos_x: number; pos_y: number; parent_id?: string }) => {
if (n.type === 'groupRect') {
const w = n.custom_colors?.width ?? 360
const h = n.custom_colors?.height ?? 240
const z = n.custom_colors?.z_order ?? 1
return {
id: n.id,
type: 'groupRect',
position: { x: n.pos_x, y: n.pos_y },
data: n,
width: w,
height: h,
zIndex: z - 10,
}
}
const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false const parentIsContainer = n.parent_id ? (proxmoxContainerMap.get(n.parent_id) ?? false) : false
return { return {
id: n.id, id: n.id,
@@ -140,13 +190,15 @@ export default function App() {
targetHandle: e.target_handle ?? null, targetHandle: e.target_handle ?? null,
data: e, data: e,
})) }))
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
loadCanvas(rfNodes, rfEdges) loadCanvas(rfNodes, rfEdges)
} else { } else {
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}) })
.catch(() => loadCanvas(demoNodes, demoEdges)) .catch(() => loadCanvas(demoNodes, demoEdges))
}, [isAuthenticated, loadCanvas]) }, [isAuthenticated, loadCanvas, setTheme])
// Ctrl+S // Ctrl+S
useEffect(() => { useEffect(() => {
@@ -181,6 +233,58 @@ export default function App() {
toast.success(`Added "${data.label}"`) toast.success(`Added "${data.label}"`)
}, [addNode, nodes]) }, [addNode, nodes])
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
const id = crypto.randomUUID()
const newNode: Node<NodeData> = {
id,
type: 'groupRect',
position: { x: 200, y: 200 },
data: {
label: data.label,
type: 'groupRect',
status: 'unknown',
services: [],
custom_colors: {
border: data.border_color,
background: data.background_color,
text_color: data.text_color,
text_position: data.text_position,
font: data.font,
z_order: data.z_order,
},
},
width: 360,
height: 240,
zIndex: data.z_order - 10,
}
addNode(newNode)
}, [addNode])
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
if (!editingGroupRectId) return
const existing = nodes.find((n) => n.id === editingGroupRectId)
updateNode(editingGroupRectId, {
label: data.label,
custom_colors: {
...existing?.data.custom_colors,
border: data.border_color,
background: data.background_color,
text_color: data.text_color,
text_position: data.text_position,
font: data.font,
z_order: data.z_order,
},
})
setNodeZIndex(editingGroupRectId, data.z_order - 10)
setEditingGroupRectId(null)
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return
deleteNode(editingGroupRectId)
setEditingGroupRectId(null)
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
const handleEditNode = useCallback((id: string) => { const handleEditNode = useCallback((id: string) => {
setEditNodeId(id) setEditNodeId(id)
}, []) }, [])
@@ -284,6 +388,7 @@ export default function App() {
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]"> <div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
<Sidebar <Sidebar
onAddNode={() => setAddNodeOpen(true)} onAddNode={() => setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
onScan={() => setScanConfigOpen(true)} onScan={() => setScanConfigOpen(true)}
onSave={handleSave} onSave={handleSave}
onNodeApproved={setEditNodeId} onNodeApproved={setEditNodeId}
@@ -293,6 +398,7 @@ export default function App() {
onSave={handleSave} onSave={handleSave}
onAutoLayout={handleAutoLayout} onAutoLayout={handleAutoLayout}
onExport={handleExport} onExport={handleExport}
onChangeStyle={() => setThemeModalOpen(true)}
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div ref={canvasRef} className="flex-1 min-w-0 h-full"> <div ref={canvasRef} className="flex-1 min-w-0 h-full">
@@ -352,6 +458,44 @@ export default function App() {
/> />
)} )}
<GroupRectModal
open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)}
onSubmit={handleAddGroupRect}
title="Add Rectangle"
/>
{/* key forces re-mount when editing a different rect */}
<GroupRectModal
key={editingGroupRectId ?? 'rect-edit'}
open={!!editingGroupRectId}
onClose={() => setEditingGroupRectId(null)}
onSubmit={handleUpdateGroupRect}
onDelete={handleDeleteGroupRect}
initial={(() => {
const n = editingGroupRectId ? nodes.find((nd) => nd.id === editingGroupRectId) : null
if (!n) return undefined
const rc = n.data.custom_colors ?? {}
return {
label: n.data.label,
font: rc.font ?? 'inter',
text_color: rc.text_color ?? '#e6edf3',
text_position: rc.text_position ?? 'top-left',
border_color: rc.border ?? '#00d4ff',
background_color: rc.background ?? '#00d4ff0d',
z_order: rc.z_order ?? 1,
}
})()}
title="Edit Rectangle"
/>
{/* key forces re-mount on open so useState captures current theme as original */}
<ThemeModal
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
/>
<Toaster theme="dark" position="bottom-right" /> <Toaster theme="dark" position="bottom-right" />
</ReactFlowProvider> </ReactFlowProvider>
</TooltipProvider> </TooltipProvider>
@@ -11,6 +11,8 @@ import {
} from '@xyflow/react' } from '@xyflow/react'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes' import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes' import { edgeTypes } from './edges/edgeTypes'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
@@ -27,6 +29,9 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
setSelectedNode, setSelectedNode,
} = useCanvasStore() } = useCanvasStore()
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
setSelectedNode(node.id) setSelectedNode(node.id)
}, [setSelectedNode]) }, [setSelectedNode])
@@ -39,9 +44,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
onEdgeDoubleClick?.(edge) onEdgeDoubleClick?.(edge)
}, [onEdgeDoubleClick]) }, [onEdgeDoubleClick])
return ( return (
<div className="w-full h-full"> <div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
@@ -56,7 +60,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
snapToGrid snapToGrid
snapGrid={[16, 16]} snapGrid={[16, 16]}
fitView fitView
colorMode="dark" colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
isValidConnection={(connection) => connection.source !== connection.target} isValidConnection={(connection) => connection.source !== connection.target}
> >
@@ -64,7 +69,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
variant={BackgroundVariant.Dots} variant={BackgroundVariant.Dots}
gap={24} gap={24}
size={1} size={1}
color="#30363d" color={theme.colors.canvasDotColor}
/> />
<Controls /> <Controls />
</ReactFlow> </ReactFlow>
+21 -14
View File
@@ -7,6 +7,8 @@ import {
type Edge, type Edge,
} from '@xyflow/react' } from '@xyflow/react'
import type { EdgeData, EdgeType } from '@/types' import type { EdgeData, EdgeType } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149'] const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
@@ -15,28 +17,33 @@ function getVlanColor(vlanId?: number): string {
return VLAN_COLORS[vlanId % VLAN_COLORS.length] return VLAN_COLORS[vlanId % VLAN_COLORS.length]
} }
const EDGE_STYLES: Record<EdgeType, React.CSSProperties> = {
ethernet: { stroke: '#30363d', strokeWidth: 2 },
wifi: { stroke: '#00d4ff', strokeWidth: 1.5, strokeDasharray: '6 3' },
iot: { stroke: '#e3b341', strokeWidth: 1.5, strokeDasharray: '2 4' },
vlan: { strokeWidth: 2.5 },
virtual: { stroke: '#8b949e', strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: '#ff6e00', strokeWidth: 2.5, strokeDasharray: '8 3' },
}
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) { export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition } const pathArgs = { sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition }
const [edgePath, labelX, labelY] = data?.path_style === 'smooth' const [edgePath, labelX, labelY] = data?.path_style === 'smooth'
? getSmoothStepPath({ ...pathArgs, borderRadius: 8 }) ? getSmoothStepPath({ ...pathArgs, borderRadius: 8 })
: getBezierPath(pathArgs) : getBezierPath(pathArgs)
const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeType: EdgeType = data?.type ?? 'ethernet'
const edgeColors = theme.colors.edgeColors
const BASE_STYLES: Record<EdgeType, React.CSSProperties> = {
ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 },
wifi: { stroke: edgeColors.wifi, strokeWidth: 1.5, strokeDasharray: '6 3' },
iot: { stroke: edgeColors.iot, strokeWidth: 1.5, strokeDasharray: '2 4' },
vlan: { strokeWidth: 2.5 },
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
}
const customColor = data?.custom_color as string | undefined const customColor = data?.custom_color as string | undefined
const style: React.CSSProperties = { const style: React.CSSProperties = {
...EDGE_STYLES[edgeType], ...BASE_STYLES[edgeType],
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
...(customColor ? { stroke: customColor } : {}), ...(customColor ? { stroke: customColor } : {}),
...(selected ? { stroke: '#00d4ff', filter: 'drop-shadow(0 0 4px #00d4ff88)' } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
} }
return ( return (
@@ -48,9 +55,9 @@ export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePo
className="absolute pointer-events-none font-mono text-[10px] px-1 rounded" className="absolute pointer-events-none font-mono text-[10px] px-1 rounded"
style={{ style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
background: '#161b22', background: theme.colors.edgeLabelBackground,
color: '#8b949e', color: theme.colors.edgeLabelColor,
border: '1px solid #30363d', border: `1px solid ${theme.colors.edgeLabelBorder}`,
}} }}
> >
{data.label as string} {data.label as string}
@@ -1,25 +1,26 @@
import { createElement } from 'react' import { createElement } from 'react'
import { Handle, Position, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'
import { type LucideIcon } from 'lucide-react' import { type LucideIcon } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { resolveNodeIcon } from '@/utils/nodeIcons' import { resolveNodeIcon } from '@/utils/nodeIcons'
import { useThemeStore } from '@/stores/themeStore'
const STATUS_COLORS: Record<NodeStatus, string> = { import { THEMES } from '@/utils/themes'
online: '#39d353', import { useCanvasStore } from '@/stores/canvasStore'
offline: '#f85149', import { maskIp } from '@/utils/maskIp'
pending: '#e3b341',
unknown: '#8b949e',
}
interface BaseNodeProps extends NodeProps<Node<NodeData>> { interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon icon: LucideIcon
} }
export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) { export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon) const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
const colors = resolveNodeColors(data) const colors = resolveNodeColors(data, activeTheme)
const statusColor = STATUS_COLORS[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
return ( return (
@@ -38,25 +39,41 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
minWidth: 140, minWidth: 140,
}} }}
> >
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" /> <Handle
type="source"
position={Position.Top}
id="top"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Icon */} {/* Icon */}
<div <div
className="flex items-center justify-center w-7 h-7 rounded-md shrink-0" className="flex items-center justify-center w-7 h-7 rounded-md shrink-0"
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }} style={{
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
background: theme.colors.nodeIconBackground,
}}
> >
{createElement(resolvedIcon, { size: 15 })} {createElement(resolvedIcon, { size: 15 })}
</div> </div>
{/* Details */} {/* Details */}
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<div className="text-xs font-medium leading-tight truncate max-w-[110px]" title={data.label}> <div
className="text-xs font-medium leading-tight truncate max-w-[110px]"
style={{ color: theme.colors.nodeLabelColor }}
title={data.label}
>
{data.label} {data.label}
</div> </div>
{data.ip && ( {data.ip && (
<div className="font-mono text-[10px] text-[#8b949e] truncate" title={data.ip}> <div
{data.ip} className="font-mono text-[10px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
title={data.ip}
>
{hideIp ? maskIp(data.ip) : data.ip}
</div> </div>
)} )}
</div> </div>
@@ -68,7 +85,12 @@ export function BaseNode({ data, selected, icon: typeIcon }: BaseNodeProps) {
title={data.status} title={data.status}
/> />
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" /> <Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
</div> </div>
) )
@@ -0,0 +1,86 @@
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
import type { NodeData, TextPosition } from '@/types'
const FONT_FAMILIES: Record<string, string> = {
inter: 'Inter, sans-serif',
mono: '"JetBrains Mono", monospace',
serif: 'Georgia, serif',
}
interface AlignStyle {
alignItems: string
justifyContent: string
textAlign: React.CSSProperties['textAlign']
}
const POSITION_STYLES: Record<TextPosition, AlignStyle> = {
'top-left': { alignItems: 'flex-start', justifyContent: 'flex-start', textAlign: 'left' },
'top-center': { alignItems: 'flex-start', justifyContent: 'center', textAlign: 'center' },
'top-right': { alignItems: 'flex-start', justifyContent: 'flex-end', textAlign: 'right' },
'middle-left': { alignItems: 'center', justifyContent: 'flex-start', textAlign: 'left' },
'center': { alignItems: 'center', justifyContent: 'center', textAlign: 'center' },
'middle-right': { alignItems: 'center', justifyContent: 'flex-end', textAlign: 'right' },
'bottom-left': { alignItems: 'flex-end', justifyContent: 'flex-start', textAlign: 'left' },
'bottom-center': { alignItems: 'flex-end', justifyContent: 'center', textAlign: 'center' },
'bottom-right': { alignItems: 'flex-end', justifyContent: 'flex-end', textAlign: 'right' },
}
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
const rc = data.custom_colors ?? {}
const borderColor = rc.border ?? '#00d4ff'
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
const textColor = rc.text_color ?? '#e6edf3'
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos]
return (
<>
<NodeResizer
isVisible={selected}
minWidth={80}
minHeight={60}
handleStyle={{
width: 8,
height: 8,
borderRadius: 2,
background: '#00d4ff',
border: '1px solid #0d1117',
}}
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
/>
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: posStyle.alignItems,
justifyContent: posStyle.justifyContent,
padding: 12,
background: backgroundColor,
border: `${selected ? 2 : 1}px solid ${selected ? '#00d4ff' : borderColor}`,
borderRadius: 10,
fontFamily,
color: textColor,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box',
cursor: 'default',
}}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingGroupRectId(id)
}}
>
{data.label && (
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
{data.label}
</span>
)}
</div>
</>
)
}
@@ -1,34 +1,46 @@
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { Layers } from 'lucide-react' import { Layers } from 'lucide-react'
import type { NodeData, NodeStatus } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
const STATUS_COLORS: Record<NodeStatus, string> = {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
}
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) { export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { data, selected } = props const { data, selected } = props
const colors = resolveNodeColors(data)
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, activeTheme)
// Render as a regular node when container mode is disabled // Render as a regular node when container mode is disabled
if (data.container_mode === false) { if (data.container_mode === false) {
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return ( return (
<> <>
<BaseNode {...props} icon={Layers} /> <BaseNode {...props} icon={Layers} />
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} /> <Handle
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} /> type="source"
position={Position.Left}
id="cluster-left"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
<Handle
type="source"
position={Position.Right}
id="cluster-right"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
</> </>
) )
} }
const statusColor = STATUS_COLORS[data.status] const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online' const isOnline = data.status === 'online'
const glow = colors.border const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return ( return (
<> <>
@@ -37,7 +49,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
minHeight={160} minHeight={160}
isVisible={selected} isVisible={selected}
lineStyle={{ borderColor: glow, opacity: 0.6 }} lineStyle={{ borderColor: glow, opacity: 0.6 }}
handleStyle={{ borderColor: glow, backgroundColor: '#21262d' }} handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
/> />
{/* Group border */} {/* Group border */}
@@ -56,38 +68,78 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
{/* Header bar */} {/* Header bar */}
<div <div
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0" className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
style={{ background: isOnline ? `${glow}18` : '#161b2288', borderBottom: `1px solid ${isOnline ? `${glow}33` : '#30363d'}` }} style={{
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
}}
> >
<div <div
className="flex items-center justify-center w-5 h-5 rounded-md shrink-0" className="flex items-center justify-center w-5 h-5 rounded-md shrink-0"
style={{ color: isOnline ? colors.icon : '#8b949e', background: '#161b22' }} style={{
color: isOnline ? colors.icon : theme.colors.nodeSubtextColor,
background: theme.colors.nodeIconBackground,
}}
> >
<Layers size={12} /> <Layers size={12} />
</div> </div>
<div className="flex flex-col min-w-0 flex-1"> <div className="flex flex-col min-w-0 flex-1">
<span className="text-[11px] font-semibold leading-tight truncate" style={{ color: isOnline ? glow : '#c9d1d9' }}> <span
className="text-[11px] font-semibold leading-tight truncate"
style={{ color: isOnline ? glow : theme.colors.nodeLabelColor }}
>
{data.label} {data.label}
</span> </span>
{data.ip && ( {data.ip && (
<span className="font-mono text-[9px] text-[#8b949e] truncate">{data.ip}</span> <span
className="font-mono text-[9px] truncate"
style={{ color: theme.colors.nodeSubtextColor }}
>
{data.ip}
</span>
)} )}
</div> </div>
{/* Status dot */} {/* Status dot */}
<div className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor }} title={data.status} /> <div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: statusColor }}
title={data.status}
/>
</div> </div>
{/* Inner area — React Flow places children here */} {/* Inner area — React Flow places children here */}
<div className="flex-1 relative" /> <div className="flex-1 relative" />
</div> </div>
<Handle type="source" position={Position.Top} id="top" className="!bg-[#30363d] !border-[#8b949e]" /> <Handle
type="source"
position={Position.Top}
id="top"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-[#30363d] !border-[#8b949e]" /> <Handle
type="source"
position={Position.Bottom}
id="bottom"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} /> <Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Cluster handles — left/right for same-cluster links */} {/* Cluster handles */}
<Handle type="source" position={Position.Left} id="cluster-left" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} /> <Handle
<Handle type="source" position={Position.Right} id="cluster-right" title="Same cluster" style={{ background: '#ff6e00', borderColor: '#ff6e0088', width: 6, height: 6 }} /> type="source"
position={Position.Left}
id="cluster-left"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
<Handle
type="source"
position={Position.Right}
id="cluster-right"
title="Same cluster"
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
/>
</> </>
) )
} }
@@ -1,5 +1,6 @@
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index' import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
export const nodeTypes = { export const nodeTypes = {
isp: IspNode, isp: IspNode,
@@ -17,4 +18,5 @@ export const nodeTypes = {
computer: ComputerNode, computer: ComputerNode,
cpl: CplNode, cpl: CplNode,
generic: GenericNode, generic: GenericNode,
groupRect: GroupRectNode,
} }
@@ -0,0 +1,202 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
export interface GroupRectFormData {
label: string
font: string
text_color: string
text_position: TextPosition
border_color: string
background_color: string
z_order: number
}
const DEFAULT_FORM: GroupRectFormData = {
label: '',
font: 'inter',
text_color: '#e6edf3',
text_position: 'top-left',
border_color: '#00d4ff',
background_color: '#00d4ff0d',
z_order: 1,
}
const FONTS = [
{ value: 'inter', label: 'Inter (sans-serif)' },
{ value: 'mono', label: 'JetBrains Mono' },
{ value: 'serif', label: 'Serif' },
]
const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
{ value: 'top-left', label: '↖' },
{ value: 'top-center', label: '↑' },
{ value: 'top-right', label: '↗' },
{ value: 'middle-left', label: '←' },
{ value: 'center', label: '·' },
{ value: 'middle-right', label: '→' },
{ value: 'bottom-left', label: '↙' },
{ value: 'bottom-center', label: '↓' },
{ value: 'bottom-right', label: '↘' },
]
interface GroupRectModalProps {
open: boolean
onClose: () => void
onSubmit: (data: GroupRectFormData) => void
onDelete?: () => void
initial?: Partial<GroupRectFormData>
title?: string
}
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(form)
onClose()
}
const colorFields = [
{ key: 'text_color' as const, label: 'Text' },
{ key: 'border_color' as const, label: 'Border' },
{ key: 'background_color' as const, label: 'Background' },
]
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
{/* Label */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label</Label>
<Input
value={form.label}
onChange={(e) => set('label', e.target.value)}
placeholder="Zone name…"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
{/* Font */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{FONTS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-sm">
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Text position 3×3 grid */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Position</Label>
<div className="grid grid-cols-3 gap-1">
{TEXT_POSITIONS.map(({ value, label }) => {
const isSelected = form.text_position === value
return (
<button
key={value}
type="button"
title={value}
onClick={() => set('text_position', value)}
className="h-8 rounded text-base transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Colors */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="color"
value={form[key]}
onChange={(e) => set(key, e.target.value)}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<span className="text-[9px] text-muted-foreground/60">{label}</span>
</div>
))}
</div>
</div>
{/* Z-order */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<SelectItem key={n} value={String(n)} className="text-sm font-mono">
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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={() => { onDelete(); onClose() }}
>
Delete
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
{title === 'Add Rectangle' ? 'Add' : 'Save'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,167 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Check } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
// Node-type accent colors to display as preview swatches
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
interface ThemeCardProps {
themeId: ThemeId
selected: boolean
onClick: () => void
}
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
const preset = THEMES[themeId]
const c = preset.colors
return (
<button
type="button"
onClick={onClick}
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
style={{
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
background: c.canvasBackground,
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
}}
>
{/* Selected checkmark */}
{selected && (
<span
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
style={{ background: c.nodeAccents.isp.border }}
>
<Check size={10} style={{ color: c.canvasBackground }} />
</span>
)}
{/* Mini canvas preview */}
<div
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
>
{/* Node accent dots */}
<div className="flex gap-1 items-center flex-wrap">
{PREVIEW_TYPES.map((type) => (
<span
key={type}
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: c.nodeAccents[type].border }}
/>
))}
</div>
{/* Edge line */}
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
{/* Wifi dashed line */}
<div
style={{
height: 1,
width: '55%',
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
}}
/>
</div>
{/* Label */}
<div
className="text-xs font-semibold leading-tight"
style={{ color: c.nodeLabelColor }}
>
{preset.label}
</div>
<div
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
style={{ color: c.nodeSubtextColor }}
>
{preset.description}
</div>
</button>
)
}
interface ThemeModalProps {
open: boolean
onClose: () => void
}
export function ThemeModal({ open, onClose }: ThemeModalProps) {
const { activeTheme, setTheme } = useThemeStore()
const { markUnsaved } = useCanvasStore()
// Capture the theme that was active when the modal opened
const [originalTheme] = useState<ThemeId>(activeTheme)
const [selected, setSelected] = useState<ThemeId>(activeTheme)
const handleSelect = (id: ThemeId) => {
setSelected(id)
// Live-preview the selected theme on the canvas
setTheme(id)
}
const handleApply = () => {
setTheme(selected)
markUnsaved()
onClose()
toast.info('Style applied — save your canvas to make it permanent', {
duration: 5000,
})
}
const handleCancel = () => {
// Revert to the original theme
setTheme(originalTheme)
onClose()
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-5 gap-3 py-1">
{THEME_ORDER.map((id) => (
<ThemeCard
key={id}
themeId={id}
selected={selected === id}
onClick={() => handleSelect(id)}
/>
))}
</div>
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground"
onClick={handleCancel}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
style={
selected !== 'default'
? { background: THEMES[selected].colors.nodeAccents.isp.border }
: undefined
}
onClick={handleApply}
>
Apply Style
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { GroupRectModal, type GroupRectFormData } from '../GroupRectModal'
describe('GroupRectModal', () => {
it('renders nothing when closed', () => {
const { container } = render(
<GroupRectModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form fields when open', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
expect(screen.getByText('Add Rectangle')).toBeDefined()
expect(screen.getByText('Text Position')).toBeDefined()
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
})
it('renders Edit Rectangle title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
expect(screen.getByText('Edit Rectangle')).toBeDefined()
})
it('calls onSubmit with form data on submit', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const input = screen.getByPlaceholderText('Zone name…')
fireEvent.change(input, { target: { value: 'DMZ' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label).toBe('DMZ')
expect(submitted.font).toBe('inter')
expect(submitted.z_order).toBe(1)
})
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce()
})
it('shows Delete button when onDelete is provided', () => {
const onDelete = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={onDelete} />)
expect(screen.getByText('Delete')).toBeDefined()
})
it('calls onDelete and onClose when Delete is clicked', () => {
const onDelete = vi.fn()
const onClose = vi.fn()
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByText('Delete'))
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('pre-fills form from initial prop', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ label: 'Pre-filled', z_order: 5, font: 'mono' }}
/>
)
const input = screen.getByPlaceholderText('Zone name…') as HTMLInputElement
expect(input.value).toBe('Pre-filled')
})
it('selects text position button', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// Click bottom-right (↘)
fireEvent.click(screen.getByTitle('bottom-right'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_position).toBe('bottom-right')
})
})
+123 -44
View File
@@ -1,20 +1,31 @@
import { X, Edit, Trash2, ExternalLink } from 'lucide-react' import { useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types' import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
import { getServiceUrl } from '@/utils/serviceUrl'
interface DetailPanelProps { interface DetailPanelProps {
onEdit: (id: string) => void onEdit: (id: string) => void
} }
export function DetailPanel({ onEdit }: DetailPanelProps) { export function DetailPanel({ onEdit }: DetailPanelProps) {
const { nodes, selectedNodeId, setSelectedNode, deleteNode } = useCanvasStore() const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
const node = nodes.find((n) => n.id === selectedNodeId) const node = nodes.find((n) => n.id === selectedNodeId)
if (!node) return null const [addingService, setAddingService] = useState(false)
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
port: '',
protocol: 'tcp',
service_name: '',
})
if (!node || node.data.type === 'groupRect') return null
const { data } = node const { data } = node
const statusColor = STATUS_COLORS[data.status] const statusColor = STATUS_COLORS[data.status]
const host = data.ip ?? data.hostname
const handleDelete = () => { const handleDelete = () => {
if (confirm(`Delete "${data.label}"?`)) { if (confirm(`Delete "${data.label}"?`)) {
@@ -22,6 +33,24 @@ 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
const svc: ServiceInfo = {
port,
protocol: newSvc.protocol,
service_name: newSvc.service_name.trim(),
}
updateNode(node.id, { services: [...(data.services ?? []), svc] })
setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
setAddingService(false)
}
const handleRemoveService = (index: number) => {
const updated = data.services.filter((_, i) => i !== index)
updateNode(node.id, { services: updated })
}
return ( return (
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto"> <aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
{/* Header */} {/* Header */}
@@ -53,24 +82,90 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.os && <DetailRow label="OS" value={data.os} />} {data.os && <DetailRow label="OS" value={data.os} />}
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />} {data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
{data.last_seen && ( {data.last_seen && (
<DetailRow <DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
label="Last Seen"
value={new Date(data.last_seen).toLocaleString()}
/>
)} )}
</div> </div>
{/* Services */} {/* Services */}
{data.services.length > 0 && ( <div className="px-4 py-3 border-t border-border">
<div className="px-4 py-3 border-t border-border"> <div className="flex items-center justify-between mb-2">
<div className="text-xs text-muted-foreground mb-2">Services ({data.services.length})</div> <span className="text-xs text-muted-foreground">
Services{data.services.length > 0 ? ` (${data.services.length})` : ''}
</span>
<button
onClick={() => setAddingService((v) => !v)}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
<Plus size={10} /> Add
</button>
</div>
{/* Add service form */}
{addingService && (
<div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={newSvc.service_name}
onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
placeholder="Service name"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex gap-1.5">
<Input
type="number"
value={newSvc.port}
onChange={(e) => setNewSvc((s) => ({ ...s, port: e.target.value }))}
placeholder="Port"
min={1}
max={65535}
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
/>
<select
value={newSvc.protocol}
onChange={(e) => setNewSvc((s) => ({ ...s, protocol: e.target.value as 'tcp' | 'udp' }))}
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
<div className="flex gap-1.5">
<Button
size="sm"
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={handleAddService}
>
Add
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={() => setAddingService(false)}
>
Cancel
</Button>
</div>
</div>
)}
{data.services.length > 0 && (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{data.services.map((svc) => ( {data.services.map((svc, i) => (
<ServiceBadge key={`${svc.port}-${svc.protocol}`} svc={svc} ip={data.ip} /> <ServiceBadge
key={`${svc.port}-${svc.protocol}-${i}`}
svc={svc}
host={host}
onRemove={() => handleRemoveService(i)}
/>
))} ))}
</div> </div>
</div> )}
)}
{data.services.length === 0 && !addingService && (
<p className="text-[10px] text-muted-foreground/50">No services click Add to register one.</p>
)}
</div>
{/* Notes */} {/* Notes */}
{data.notes && ( {data.notes && (
@@ -82,20 +177,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{/* Actions */} {/* Actions */}
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border"> <div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
<Button <Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
size="sm"
variant="secondary"
className="flex-1 gap-1.5"
onClick={() => onEdit(node.id)}
>
<Edit size={14} /> Edit <Edit size={14} /> Edit
</Button> </Button>
<Button <Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDelete}>
size="sm"
variant="destructive"
className="gap-1.5"
onClick={handleDelete}
>
<Trash2 size={14} /> <Trash2 size={14} />
</Button> </Button>
</div> </div>
@@ -126,39 +211,33 @@ const CATEGORY_COLORS: Record<string, string> = {
remote: '#8b949e', remote: '#8b949e',
} }
function getServiceUrl(svc: ServiceInfo, ip?: string): string | null { function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
if (!ip) return null const url = getServiceUrl(svc, host)
const name = svc.service_name.toLowerCase()
const isHttps = name.includes('https') || name.includes('ssl') || svc.port === 443 || svc.port === 8443
const isHttp = name.includes('http') || [80, 8080, 8000, 3000, 8888, 9000, 8090, 7080].includes(svc.port)
if (isHttps) return `https://${ip}:${svc.port}`
if (isHttp) return `http://${ip}:${svc.port}`
return null
}
function ServiceBadge({ svc, ip }: { svc: ServiceInfo; ip?: string }) {
const url = getServiceUrl(svc, ip)
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e' const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
const inner = ( const inner = (
<div <div
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
style={{ style={{
background: '#21262d', background: '#21262d',
borderColor: '#30363d', borderColor: '#30363d',
...(url ? { cursor: 'pointer' } : {}), cursor: url ? 'pointer' : 'default',
}} }}
> >
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span <span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
className="shrink-0 w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span> <span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span> <span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
{url && <ExternalLink size={10} className="text-muted-foreground" />} {url && <ExternalLink size={10} className="text-muted-foreground" />}
<button
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
title="Remove service"
>
<X size={10} />
</button>
</div> </div>
</div> </div>
) )
+19 -13
View File
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2 } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
@@ -30,18 +31,20 @@ interface ScanRun {
interface SidebarProps { interface SidebarProps {
onAddNode: () => void onAddNode: () => void
onAddGroupRect: () => void
onScan: () => void onScan: () => void
onSave: () => void onSave: () => void
onNodeApproved: (nodeId: string) => void onNodeApproved: (nodeId: string) => void
} }
export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [activeView, setActiveView] = useState<SidebarView>('canvas') const [activeView, setActiveView] = useState<SidebarView>('canvas')
const { nodes, hasUnsavedChanges } = useCanvasStore() const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
const onlineCount = nodes.filter((n) => n.data.status === 'online').length const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
const offlineCount = nodes.filter((n) => n.data.status === 'offline').length const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
const handleScan = useCallback(async () => { const handleScan = useCallback(async () => {
try { try {
@@ -68,13 +71,8 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
</button> </button>
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 px-3 py-4 border-b border-border"> <div className="flex items-center px-3 py-4 border-b border-border overflow-hidden">
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-[#00d4ff]/10 text-[#00d4ff] shrink-0"> <Logo size={28} showText={!collapsed} />
<Network size={16} />
</div>
{!collapsed && (
<span className="font-semibold text-sm tracking-wide text-foreground">Homelable</span>
)}
</div> </div>
{/* Views */} {/* Views */}
@@ -110,7 +108,7 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5"> <div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
<div className="flex justify-between"> <div className="flex justify-between">
<span>Total</span> <span>Total</span>
<span className="text-foreground font-mono">{nodes.length}</span> <span className="text-foreground font-mono">{networkNodes.length}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-[#39d353]">Online</span> <span className="text-[#39d353]">Online</span>
@@ -126,7 +124,15 @@ export function Sidebar({ onAddNode, onScan, onSave, onNodeApproved }: SidebarPr
{/* Actions */} {/* Actions */}
<div className="flex flex-col gap-0.5 p-2 border-t border-border"> <div className="flex flex-col gap-0.5 p-2 border-t border-border">
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} /> <SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
<SidebarItem icon={Square} label="Add Rectangle" collapsed={collapsed} onClick={onAddGroupRect} />
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />} {!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
<SidebarItem
icon={hideIp ? EyeOff : Eye}
label={hideIp ? 'Show IPs' : 'Hide IPs'}
collapsed={collapsed}
onClick={toggleHideIp}
active={hideIp}
/>
<SidebarItem <SidebarItem
icon={Save} icon={Save}
label="Save Canvas" label="Save Canvas"
+8 -2
View File
@@ -1,22 +1,28 @@
import { Save, LayoutDashboard, Download } from 'lucide-react' import { Save, LayoutDashboard, Download, Palette } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/ui/Logo'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
interface ToolbarProps { interface ToolbarProps {
onSave: () => void onSave: () => void
onAutoLayout: () => void onAutoLayout: () => void
onExport: () => void onExport: () => void
onChangeStyle: () => void
} }
export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) { export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle }: ToolbarProps) {
const { hasUnsavedChanges } = useCanvasStore() const { hasUnsavedChanges } = useCanvasStore()
return ( return (
<header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0"> <header className="flex items-center gap-2 px-4 py-2 border-b border-border bg-[#161b22] shrink-0">
<Logo size={28} showText={true} />
<div className="flex-1" /> <div className="flex-1" />
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
<LayoutDashboard size={14} /> Auto Layout <LayoutDashboard size={14} /> Auto Layout
</Button> </Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
<Palette size={14} /> Style
</Button>
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}> <Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport}>
<Download size={14} /> Export <Download size={14} /> Export
</Button> </Button>
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { getServiceUrl } from '@/utils/serviceUrl'
import type { ServiceInfo } from '@/types'
const svc = (port: number, protocol: 'tcp' | 'udp' = 'tcp', service_name = 'test'): ServiceInfo => ({
port,
protocol,
service_name,
})
describe('getServiceUrl', () => {
it('returns null when no host is provided', () => {
expect(getServiceUrl(svc(80), undefined)).toBeNull()
})
it('returns null for port 22 (SSH)', () => {
expect(getServiceUrl(svc(22, 'tcp', 'ssh'), '192.168.1.1')).toBeNull()
})
it('returns null for UDP services', () => {
expect(getServiceUrl(svc(53, 'udp', 'dns'), '192.168.1.1')).toBeNull()
})
it('returns null for known non-HTTP TCP ports', () => {
expect(getServiceUrl(svc(3306, 'tcp', 'mysql'), '192.168.1.1')).toBeNull()
expect(getServiceUrl(svc(5432, 'tcp', 'postgres'), '192.168.1.1')).toBeNull()
expect(getServiceUrl(svc(6379, 'tcp', 'redis'), '192.168.1.1')).toBeNull()
expect(getServiceUrl(svc(27017, 'tcp', 'mongodb'), '192.168.1.1')).toBeNull()
})
it('returns http URL for standard HTTP port', () => {
expect(getServiceUrl(svc(80, 'tcp', 'http'), '192.168.1.10')).toBe('http://192.168.1.10:80')
})
it('returns https URL for port 443', () => {
expect(getServiceUrl(svc(443, 'tcp', 'https'), '10.0.0.1')).toBe('https://10.0.0.1:443')
})
it('returns https URL for port 8443', () => {
expect(getServiceUrl(svc(8443), '10.0.0.1')).toBe('https://10.0.0.1:8443')
})
it('returns https URL when service name contains ssl', () => {
expect(getServiceUrl(svc(9443, 'tcp', 'my-ssl-app'), '10.0.0.1')).toBe('https://10.0.0.1:9443')
})
it('returns http URL for Sonarr on port 8989', () => {
expect(getServiceUrl(svc(8989, 'tcp', 'Sonarr'), '192.168.1.5')).toBe('http://192.168.1.5:8989')
})
it('returns http URL for Radarr on port 7878', () => {
expect(getServiceUrl(svc(7878, 'tcp', 'Radarr'), '192.168.1.5')).toBe('http://192.168.1.5:7878')
})
it('returns http URL for Jellyfin on port 8096', () => {
expect(getServiceUrl(svc(8096, 'tcp', 'Jellyfin'), '192.168.1.5')).toBe('http://192.168.1.5:8096')
})
it('returns http URL for Proxmox web UI on port 8006', () => {
expect(getServiceUrl(svc(8006, 'tcp', 'Proxmox'), '192.168.1.100')).toBe('http://192.168.1.100:8006')
})
it('uses host string directly (works with both IP and hostname)', () => {
expect(getServiceUrl(svc(80), 'myserver.lan')).toBe('http://myserver.lan:80')
})
})
+46
View File
@@ -0,0 +1,46 @@
interface LogoProps {
size?: number;
showText?: boolean;
className?: string;
}
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<svg
width={size}
height={size}
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="32" cy="32" r="32" fill="#0d1117" />
<path
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
fill="#161b22"
stroke="#00d4ff"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
<circle cx="22" cy="38" r="2" fill="#39d353" />
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="42" cy="38" r="2" fill="#39d353" />
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="24" r="2" fill="#a855f7" />
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
</svg>
{showText && (
<span
className="font-bold tracking-tight"
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
>
<span style={{ color: '#00d4ff' }}>Home</span>
<span style={{ color: '#ffffff' }}>lable</span>
</span>
)}
</div>
);
}
+1 -1
View File
@@ -90,7 +90,7 @@
/* React Flow overrides */ /* React Flow overrides */
.react-flow__background { .react-flow__background {
background-color: var(--surface-base) !important; background-color: transparent;
} }
.react-flow__minimap { .react-flow__minimap {
background-color: var(--surface-elevated) !important; background-color: var(--surface-elevated) !important;
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
editingGroupRectId: null,
}) })
}) })
@@ -190,6 +191,40 @@ describe('canvasStore', () => {
expect(updatedChild?.extent).toBeUndefined() expect(updatedChild?.extent).toBeUndefined()
}) })
it('setEditingGroupRectId sets and clears the editing id', () => {
useCanvasStore.getState().setEditingGroupRectId('rect-1')
expect(useCanvasStore.getState().editingGroupRectId).toBe('rect-1')
useCanvasStore.getState().setEditingGroupRectId(null)
expect(useCanvasStore.getState().editingGroupRectId).toBeNull()
})
it('setNodeZIndex updates the node zIndex and marks unsaved', () => {
useCanvasStore.getState().addNode(makeNode('n1'))
useCanvasStore.getState().markSaved()
useCanvasStore.getState().setNodeZIndex('n1', -5)
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
expect(node?.zIndex).toBe(-5)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('addNode with groupRect type preserves zIndex and dimensions', () => {
const rectNode: Node<NodeData> = {
id: 'rect-1',
type: 'groupRect',
position: { x: 100, y: 100 },
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
width: 360,
height: 240,
zIndex: -9,
}
useCanvasStore.getState().addNode(rectNode)
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'rect-1')
expect(stored?.type).toBe('groupRect')
expect(stored?.zIndex).toBe(-9)
expect(stored?.width).toBe(360)
expect(stored?.height).toBe(240)
})
it('loadCanvas sorts parents before children', () => { it('loadCanvas sorts parents before children', () => {
const parent = makeNode('p1') const parent = makeNode('p1')
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' } const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
@@ -0,0 +1,31 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useThemeStore } from '@/stores/themeStore'
describe('themeStore', () => {
beforeEach(() => {
useThemeStore.setState({ activeTheme: 'default' })
})
it('starts with default theme', () => {
expect(useThemeStore.getState().activeTheme).toBe('default')
})
it('setTheme updates activeTheme', () => {
useThemeStore.getState().setTheme('matrix')
expect(useThemeStore.getState().activeTheme).toBe('matrix')
})
it('setTheme can switch between all presets', () => {
const themes = ['default', 'dark', 'light', 'neon', 'matrix'] as const
for (const id of themes) {
useThemeStore.getState().setTheme(id)
expect(useThemeStore.getState().activeTheme).toBe(id)
}
})
it('setTheme back to default after neon', () => {
useThemeStore.getState().setTheme('neon')
useThemeStore.getState().setTheme('default')
expect(useThemeStore.getState().activeTheme).toBe('default')
})
})
+20
View File
@@ -28,9 +28,15 @@ interface CanvasState {
updateEdge: (id: string, data: Partial<EdgeData>) => void updateEdge: (id: string, data: Partial<EdgeData>) => void
deleteEdge: (id: string) => void deleteEdge: (id: string) => void
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
setNodeZIndex: (id: string, zIndex: number) => void
editingGroupRectId: string | null
setEditingGroupRectId: (id: string | null) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => void
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
notifyScanDeviceFound: () => void notifyScanDeviceFound: () => void
hideIp: boolean
toggleHideIp: () => void
} }
export const useCanvasStore = create<CanvasState>((set) => ({ export const useCanvasStore = create<CanvasState>((set) => ({
@@ -38,6 +44,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
edges: [], edges: [],
hasUnsavedChanges: false, hasUnsavedChanges: false,
selectedNodeId: null, selectedNodeId: null,
editingGroupRectId: null,
hideIp: false,
scanEventTs: 0, scanEventTs: 0,
onNodesChange: (changes) => onNodesChange: (changes) =>
@@ -141,10 +149,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
return { nodes, hasUnsavedChanges: true } return { nodes, hasUnsavedChanges: true }
}), }),
setNodeZIndex: (id, zIndex) =>
set((state) => ({
nodes: state.nodes.map((n) => n.id === id ? { ...n, zIndex } : n),
hasUnsavedChanges: true,
})),
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
markSaved: () => set({ hasUnsavedChanges: false }), markSaved: () => set({ hasUnsavedChanges: false }),
markUnsaved: () => set({ hasUnsavedChanges: true }),
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }), notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
loadCanvas: (nodes, edges) => { loadCanvas: (nodes, edges) => {
// React Flow requires parents before children in the array // React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId) const parents = nodes.filter((n) => !n.parentId)
+12
View File
@@ -0,0 +1,12 @@
import { create } from 'zustand'
import type { ThemeId } from '@/utils/themes'
interface ThemeState {
activeTheme: ThemeId
setTheme: (id: ThemeId) => void
}
export const useThemeStore = create<ThemeState>((set) => ({
activeTheme: 'default',
setTheme: (id) => set({ activeTheme: id }),
}))
+25 -1
View File
@@ -14,6 +14,18 @@ export type NodeType =
| 'computer' | 'computer'
| 'cpl' | 'cpl'
| 'generic' | 'generic'
| 'groupRect'
export type TextPosition =
| 'top-left'
| 'top-center'
| 'top-right'
| 'middle-left'
| 'center'
| 'middle-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
@@ -45,7 +57,18 @@ export interface NodeData extends Record<string, unknown> {
notes?: string notes?: string
parent_id?: string parent_id?: string
container_mode?: boolean container_mode?: boolean
custom_colors?: { border?: string; background?: string; icon?: string } custom_colors?: {
border?: string
background?: string
icon?: string
// Group rectangle extras (type === 'groupRect')
text_color?: string
text_position?: TextPosition
font?: string
z_order?: number
width?: number
height?: number
}
custom_icon?: string custom_icon?: string
} }
@@ -76,6 +99,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
computer: 'Computer', computer: 'Computer',
cpl: 'CPL / Powerline', cpl: 'CPL / Powerline',
generic: 'Generic Device', generic: 'Generic Device',
groupRect: 'Group Rectangle',
} }
export const STATUS_COLORS: Record<NodeStatus, string> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import { applyDagreLayout } from '../layout'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
function makeNode(id: string, type: string, parentId?: string): Node<NodeData> {
return {
id,
type,
position: { x: 0, y: 0 },
data: { type, label: id } as unknown as NodeData,
...(parentId ? { parentId } : {}),
}
}
function makeEdge(source: string, target: string, sourceHandle?: string): Edge<EdgeData> {
return { id: `${source}-${target}`, source, target, sourceHandle, data: {} as EdgeData }
}
describe('applyDagreLayout', () => {
it('places two proxmox nodes connected to each other at the same Y', () => {
const nodes = [
makeNode('router', 'router'),
makeNode('pve1', 'proxmox'),
makeNode('pve2', 'proxmox'),
]
const edges = [
makeEdge('router', 'pve1'),
makeEdge('router', 'pve2'),
makeEdge('pve1', 'pve2'),
]
const result = applyDagreLayout(nodes, edges)
const pve1 = result.find((n) => n.id === 'pve1')!
const pve2 = result.find((n) => n.id === 'pve2')!
expect(pve1.position.y).toBe(pve2.position.y)
})
it('orders peer nodes left-to-right by chain: endpoint first, middle last', () => {
// pve-left -- pve-center -- pve-right (chain)
// router connects to all three
const nodes = [
makeNode('router', 'router'),
makeNode('pve-left', 'proxmox'),
makeNode('pve-center', 'proxmox'),
makeNode('pve-right', 'proxmox'),
]
const edges = [
makeEdge('router', 'pve-left'),
makeEdge('router', 'pve-center'),
makeEdge('router', 'pve-right'),
makeEdge('pve-left', 'pve-center'),
makeEdge('pve-center', 'pve-right'),
]
const result = applyDagreLayout(nodes, edges)
const left = result.find((n) => n.id === 'pve-left')!
const center = result.find((n) => n.id === 'pve-center')!
const right = result.find((n) => n.id === 'pve-right')!
// All at same Y
expect(left.position.y).toBe(center.position.y)
expect(center.position.y).toBe(right.position.y)
// X order: endpoint (left or right) < center (middle has 2 peer connections)
// The BFS starts from an endpoint, so we just verify the middle is not at the extremes
const xs = [left.position.x, center.position.x, right.position.x].sort((a, b) => a - b)
expect(center.position.x).toBe(xs[1]) // pve-center must be in the middle
})
it('keeps child nodes (parentId set) in place', () => {
const nodes = [
makeNode('router', 'router'),
makeNode('pve1', 'proxmox'),
makeNode('vm1', 'vm', 'pve1'),
]
const edges = [makeEdge('router', 'pve1')]
const result = applyDagreLayout(nodes, edges)
const vm1 = result.find((n) => n.id === 'vm1')!
expect(vm1.position).toEqual({ x: 0, y: 0 })
})
it('places a node below its parent when the edge exits from the top handle (upward edge)', () => {
// Frigate connects UP to Proxmox via its top handle (source=Frigate, sourceHandle='top')
// Dagre must place Frigate BELOW Proxmox, not above.
const nodes = [
makeNode('router', 'router'),
makeNode('proxmox', 'proxmox'),
makeNode('frigate', 'server'),
]
const edges = [
makeEdge('router', 'proxmox'),
makeEdge('frigate', 'proxmox', 'top'), // upward edge: frigate → proxmox via top handle
]
const result = applyDagreLayout(nodes, edges)
const proxmox = result.find((n) => n.id === 'proxmox')!
const frigate = result.find((n) => n.id === 'frigate')!
expect(frigate.position.y).toBeGreaterThan(proxmox.position.y)
})
it('places two switch nodes connected to each other at the same Y', () => {
const nodes = [
makeNode('router', 'router'),
makeNode('sw1', 'switch'),
makeNode('sw2', 'switch'),
]
const edges = [
makeEdge('router', 'sw1'),
makeEdge('router', 'sw2'),
makeEdge('sw1', 'sw2'),
]
const result = applyDagreLayout(nodes, edges)
const sw1 = result.find((n) => n.id === 'sw1')!
const sw2 = result.find((n) => n.id === 'sw2')!
expect(sw1.position.y).toBe(sw2.position.y)
})
})
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { maskIp } from '../maskIp'
describe('maskIp', () => {
it('masks last two octets of a standard IPv4', () => {
expect(maskIp('192.168.1.115')).toBe('192.168.XX.XX')
})
it('masks any IPv4', () => {
expect(maskIp('10.0.0.1')).toBe('10.0.XX.XX')
expect(maskIp('172.16.254.1')).toBe('172.16.XX.XX')
})
it('passes through non-IPv4 strings unchanged', () => {
expect(maskIp('hostname')).toBe('hostname')
expect(maskIp('fe80::1')).toBe('fe80::1')
expect(maskIp('')).toBe('')
})
})
@@ -52,4 +52,25 @@ describe('resolveNodeColors', () => {
const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined }) const result = resolveNodeColors({ type: 'unknown' as any, custom_colors: undefined })
expect(result).toEqual(NODE_DEFAULT_COLORS.generic) expect(result).toEqual(NODE_DEFAULT_COLORS.generic)
}) })
it('uses theme colors when themeId is provided', () => {
const result = resolveNodeColors(makeData({ type: 'server' }), 'matrix')
expect(result.border).not.toBe(NODE_DEFAULT_COLORS.server.border)
expect(result.border).toBe('#008822') // matrix server accent
})
it('custom_colors override theme colors', () => {
const custom = { border: '#ff0000' }
const result = resolveNodeColors(makeData({ type: 'server', custom_colors: custom }), 'neon')
expect(result.border).toBe('#ff0000')
// background should come from neon theme, not default
expect(result.background).not.toBe(NODE_DEFAULT_COLORS.server.background)
})
it('groupRect always has transparent background regardless of theme', () => {
for (const themeId of ['default', 'light', 'matrix', 'neon', 'dark'] as const) {
const result = resolveNodeColors(makeData({ type: 'groupRect' }), themeId)
expect(result.background).toBe('transparent')
}
})
}) })
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
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',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'generic', 'groupRect',
]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
describe('THEME_ORDER', () => {
it('contains all theme IDs', () => {
const keys = Object.keys(THEMES) as ThemeId[]
expect(THEME_ORDER).toHaveLength(keys.length)
expect(new Set(THEME_ORDER)).toEqual(new Set(keys))
})
})
describe('THEMES', () => {
for (const themeId of Object.keys(THEMES) as ThemeId[]) {
describe(`theme: ${themeId}`, () => {
const preset = THEMES[themeId]
it('has id, label, description', () => {
expect(preset.id).toBe(themeId)
expect(typeof preset.label).toBe('string')
expect(preset.label.length).toBeGreaterThan(0)
expect(typeof preset.description).toBe('string')
})
it('has nodeAccents for all node types', () => {
for (const type of NODE_TYPES) {
expect(preset.colors.nodeAccents[type]).toBeDefined()
expect(typeof preset.colors.nodeAccents[type].border).toBe('string')
expect(typeof preset.colors.nodeAccents[type].icon).toBe('string')
}
})
it('has surface colors', () => {
expect(typeof preset.colors.nodeCardBackground).toBe('string')
expect(typeof preset.colors.nodeIconBackground).toBe('string')
expect(typeof preset.colors.nodeLabelColor).toBe('string')
expect(typeof preset.colors.nodeSubtextColor).toBe('string')
})
it('has statusColors for all statuses', () => {
for (const status of STATUS_TYPES) {
expect(typeof preset.colors.statusColors[status]).toBe('string')
}
})
it('has edgeColors for all edge types', () => {
for (const type of EDGE_TYPES) {
expect(typeof preset.colors.edgeColors[type]).toBe('string')
}
})
it('has edge label colors', () => {
expect(typeof preset.colors.edgeSelectedColor).toBe('string')
expect(typeof preset.colors.edgeLabelBackground).toBe('string')
expect(typeof preset.colors.edgeLabelColor).toBe('string')
expect(typeof preset.colors.edgeLabelBorder).toBe('string')
})
it('has canvas colors', () => {
expect(typeof preset.colors.canvasBackground).toBe('string')
expect(typeof preset.colors.canvasDotColor).toBe('string')
})
it('has handle colors', () => {
expect(typeof preset.colors.handleBackground).toBe('string')
expect(typeof preset.colors.handleBorder).toBe('string')
})
it('has valid reactFlowColorMode', () => {
expect(['dark', 'light']).toContain(preset.colors.reactFlowColorMode)
})
})
}
it('default theme matches original hardcoded colors', () => {
const d = THEMES.default.colors
expect(d.nodeAccents.server.border).toBe('#a855f7')
expect(d.nodeAccents.isp.border).toBe('#00d4ff')
expect(d.nodeAccents.proxmox.border).toBe('#ff6e00')
expect(d.nodeCardBackground).toBe('#21262d')
expect(d.nodeIconBackground).toBe('#161b22')
expect(d.canvasBackground).toBe('#0d1117')
expect(d.canvasDotColor).toBe('#30363d')
expect(d.edgeColors.ethernet).toBe('#30363d')
expect(d.edgeColors.wifi).toBe('#00d4ff')
expect(d.statusColors.online).toBe('#39d353')
expect(d.statusColors.offline).toBe('#f85149')
})
it('light theme has reactFlowColorMode light', () => {
expect(THEMES.light.colors.reactFlowColorMode).toBe('light')
})
it('light theme has light canvas background', () => {
expect(THEMES.light.colors.canvasBackground).toBe('#f6f8fa')
})
it('matrix theme uses green accents', () => {
const m = THEMES.matrix.colors
expect(m.nodeAccents.isp.border).toMatch(/^#0/)
expect(m.nodeLabelColor).toBe('#00ff41')
expect(m.canvasBackground).toBe('#000000')
})
})
+117 -14
View File
@@ -5,46 +5,149 @@ import type { NodeData, EdgeData } from '@/types'
const NODE_WIDTH = 180 const NODE_WIDTH = 180
const NODE_HEIGHT = 52 const NODE_HEIGHT = 52
const PEER_TYPES = new Set(['proxmox', 'switch'])
/**
* Find groups of peer nodes (same type, directly connected to each other)
* using union-find. Returns a map: nodeId → groupId (the minimum nodeId in the group).
*/
function buildPeerGroups(
topLevel: Node<NodeData>[],
edges: Edge<EdgeData>[],
): Map<string, string> {
const parent = new Map<string, string>(topLevel.map((n) => [n.id, n.id]))
function find(id: string): string {
if (parent.get(id) !== id) parent.set(id, find(parent.get(id)!))
return parent.get(id)!
}
function union(a: string, b: string) {
const ra = find(a), rb = find(b)
if (ra !== rb) parent.set(ra, rb)
}
const topLevelIds = new Set(topLevel.map((n) => n.id))
const peerIds = new Set(topLevel.filter((n) => PEER_TYPES.has(n.type ?? '')).map((n) => n.id))
for (const edge of edges) {
const { source: s, target: t } = edge
if (topLevelIds.has(s) && topLevelIds.has(t) && peerIds.has(s) && peerIds.has(t)) {
// Only merge if both nodes share the same type (proxmox↔proxmox, switch↔switch)
const srcNode = topLevel.find((n) => n.id === s)!
const tgtNode = topLevel.find((n) => n.id === t)!
if (srcNode.type === tgtNode.type) union(s, t)
}
}
// Resolve all to canonical group ids
const result = new Map<string, string>()
for (const n of topLevel) result.set(n.id, find(n.id))
return result
}
/** /**
* Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges. * Apply Dagre hierarchical (top-to-bottom) layout to nodes and edges.
* Child nodes (parentId set) keep their relative position inside the parent — only * Child nodes (parentId set) keep their relative position inside the parent — only
* top-level nodes are repositioned by Dagre. * top-level nodes are repositioned by Dagre.
*
* Post-pass: peer nodes of the same type (proxmox, switch) connected to each other
* are snapped to the same Y rank so they appear on the same horizontal level.
*/ */
export function applyDagreLayout( export function applyDagreLayout(
nodes: Node<NodeData>[], nodes: Node<NodeData>[],
edges: Edge<EdgeData>[], edges: Edge<EdgeData>[],
): Node<NodeData>[] { ): Node<NodeData>[] {
const topLevel = nodes.filter((n) => !n.parentId)
const topLevelIds = new Set(topLevel.map((n) => n.id))
// Capture original X positions before Dagre — used to preserve left-to-right
// ordering of peer groups as the user set them.
const originalX = new Map<string, number>(topLevel.map((n) => [n.id, n.position.x]))
// Identify peer groups before running Dagre so we can exclude peer edges
const peerGroups = buildPeerGroups(topLevel, edges)
const isPeerEdge = (e: Edge<EdgeData>) => {
const sg = peerGroups.get(e.source)
const tg = peerGroups.get(e.target)
return sg !== undefined && tg !== undefined && sg === tg
}
const g = new dagre.graphlib.Graph() const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({})) g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 }) g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 })
const topLevel = nodes.filter((n) => !n.parentId)
for (const node of topLevel) { for (const node of topLevel) {
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
g.setNode(node.id, { width: w, height: h }) g.setNode(node.id, { width: w, height: h })
} }
for (const edge of edges) { for (const edge of edges) {
// Only add edges between top-level nodes const srcTop = topLevelIds.has(edge.source)
const srcTop = topLevel.some((n) => n.id === edge.source) const tgtTop = topLevelIds.has(edge.target)
const tgtTop = topLevel.some((n) => n.id === edge.target) // Exclude peer-to-peer edges — they confuse Dagre's rank assignment
if (srcTop && tgtTop) g.setEdge(edge.source, edge.target) if (!srcTop || !tgtTop || isPeerEdge(edge)) continue
// If the edge exits from the TOP handle of the source, the connection goes
// upward — meaning the source node is visually below the target. Reverse
// the edge direction for Dagre so it places the source below the target.
const upward = (edge as { sourceHandle?: string | null }).sourceHandle === 'top'
if (upward) {
g.setEdge(edge.target, edge.source)
} else {
g.setEdge(edge.source, edge.target)
}
} }
dagre.layout(g) dagre.layout(g)
return nodes.map((node) => { // Build initial positions from Dagre
if (node.parentId) return node // keep children in place const positions = new Map<string, { x: number; y: number; w: number; h: number }>()
for (const node of topLevel) {
const pos = g.node(node.id) const pos = g.node(node.id)
const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH const w = node.type === 'proxmox' ? (node.width ?? 300) : NODE_WIDTH
const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT const h = node.type === 'proxmox' ? (node.height ?? 200) : NODE_HEIGHT
return { positions.set(node.id, { x: pos.x - w / 2, y: pos.y - h / 2, w, h })
...node, }
position: {
x: pos.x - w / 2, // Post-pass: fix peer groups (same-type nodes directly connected to each other)
y: pos.y - h / 2, // Collect members per group
}, const groupMembers = new Map<string, string[]>()
for (const [id, groupId] of peerGroups) {
if (!groupMembers.has(groupId)) groupMembers.set(groupId, [])
groupMembers.get(groupId)!.push(id)
}
for (const [, members] of groupMembers) {
if (members.length < 2) continue
// --- Y: snap all to average Y of the group ---
const avgY = members.reduce((sum, id) => sum + positions.get(id)!.y, 0) / members.length
for (const id of members) positions.set(id, { ...positions.get(id)!, y: avgY })
// --- X: sort by original (pre-layout) X to preserve the user's intended
// left-to-right order. Fall back to Dagre X if all nodes share the
// same original X (e.g. freshly created canvas with no positions yet). ---
const GAP = 60
const origXs = members.map((id) => originalX.get(id) ?? 0)
const allSameOrigX = origXs.every((x) => x === origXs[0])
const ordered = members.slice().sort((a, b) =>
allSameOrigX
? positions.get(a)!.x - positions.get(b)!.x // fall back to Dagre X
: (originalX.get(a) ?? 0) - (originalX.get(b) ?? 0), // preserve user order
)
const totalWidth = ordered.reduce((sum, id) => sum + positions.get(id)!.w, 0) + GAP * (ordered.length - 1)
const centerX = members.reduce((sum, id) => sum + positions.get(id)!.x + positions.get(id)!.w / 2, 0) / members.length
let curX = centerX - totalWidth / 2
for (const id of ordered) {
const p = positions.get(id)!
positions.set(id, { ...p, x: curX })
curX += p.w + GAP
} }
}
return nodes.map((node) => {
if (node.parentId) return node
const p = positions.get(node.id)!
return { ...node, position: { x: p.x, y: p.y } }
}) })
} }
+10
View File
@@ -0,0 +1,10 @@
/**
* Mask the last two octets of an IPv4 address.
* e.g. "192.168.1.115" → "192.168.XX.XX"
* Non-IPv4 strings are returned unchanged.
*/
export function maskIp(ip: string): string {
const parts = ip.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.XX.XX`
return ip
}
+25 -19
View File
@@ -1,4 +1,5 @@
import type { NodeData, NodeType } from '@/types' import type { NodeData, NodeType } from '@/types'
import { THEMES, type ThemeId } from './themes'
export interface NodeColors { export interface NodeColors {
border: string border: string
@@ -6,26 +7,31 @@ export interface NodeColors {
icon: string icon: string
} }
export const NODE_DEFAULT_COLORS: Record<NodeType, NodeColors> = { // Derived from the default theme — kept for backward compatibility and tests
isp: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, export const NODE_DEFAULT_COLORS: Record<NodeType, NodeColors> = Object.fromEntries(
router: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, (Object.entries(THEMES.default.colors.nodeAccents) as [NodeType, { border: string; icon: string }][]).map(
switch: { border: '#39d353', background: '#21262d', icon: '#39d353' }, ([type, accent]) => [
server: { border: '#a855f7', background: '#21262d', icon: '#a855f7' }, type,
proxmox: { border: '#ff6e00', background: '#21262d', icon: '#ff6e00' }, {
vm: { border: '#a855f7', background: '#21262d', icon: '#a855f7' }, border: accent.border,
lxc: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, background: type === 'groupRect' ? 'transparent' : THEMES.default.colors.nodeCardBackground,
nas: { border: '#39d353', background: '#21262d', icon: '#39d353' }, icon: accent.icon,
iot: { border: '#e3b341', background: '#21262d', icon: '#e3b341' }, },
ap: { border: '#00d4ff', background: '#21262d', icon: '#00d4ff' }, ]
camera: { border: '#8b949e', background: '#21262d', icon: '#8b949e' }, )
printer: { border: '#8b949e', background: '#21262d', icon: '#8b949e' }, ) as Record<NodeType, NodeColors>
computer: { border: '#a855f7', background: '#21262d', icon: '#a855f7' },
cpl: { border: '#e3b341', background: '#21262d', icon: '#e3b341' },
generic: { border: '#8b949e', background: '#21262d', icon: '#8b949e' },
}
export function resolveNodeColors(data: Pick<NodeData, 'type' | 'custom_colors'>): NodeColors { export function resolveNodeColors(
const defaults = NODE_DEFAULT_COLORS[data.type] ?? NODE_DEFAULT_COLORS.generic data: Pick<NodeData, 'type' | 'custom_colors'>,
themeId: ThemeId = 'default',
): NodeColors {
const theme = THEMES[themeId] ?? THEMES.default
const accent = theme.colors.nodeAccents[data.type] ?? theme.colors.nodeAccents.generic
const defaults: NodeColors = {
border: accent.border,
background: data.type === 'groupRect' ? 'transparent' : theme.colors.nodeCardBackground,
icon: accent.icon,
}
const custom = data.custom_colors const custom = data.custom_colors
return { return {
border: custom?.border ?? defaults.border, border: custom?.border ?? defaults.border,
+34
View File
@@ -0,0 +1,34 @@
import type { ServiceInfo } from '@/types'
// Ports that are definitely not HTTP/web services (port 22/SSH handled separately)
const NON_HTTP_PORTS = new Set([
21, // FTP
23, // Telnet
25, 465, 587, // SMTP
53, // DNS
110, 143, 993, 995, // IMAP / POP3
389, 636, // LDAP
445, // SMB
514, // Syslog
1433, // MSSQL
3306, // MySQL
5432, // PostgreSQL
5672, // RabbitMQ AMQP
6379, // Redis
9092, // Kafka
11211, // Memcached
27017, 27018, // MongoDB
])
export function getServiceUrl(svc: ServiceInfo, host?: string): string | null {
if (!host) return null
if (svc.port === 22) return null // SSH — no browser
if (svc.protocol === 'udp') return null // UDP — not HTTP
if (NON_HTTP_PORTS.has(svc.port)) return null
const name = svc.service_name.toLowerCase()
const isHttps =
name.includes('https') || name.includes('ssl') || name.includes('tls') ||
svc.port === 443 || svc.port === 8443
return `${isHttps ? 'https' : 'http'}://${host}:${svc.port}`
}
+306
View File
@@ -0,0 +1,306 @@
import type { NodeType, EdgeType, NodeStatus } from '@/types'
export type ThemeId = 'default' | 'dark' | 'light' | 'neon' | 'matrix'
export interface ThemeColors {
// Per node-type accent (border + icon)
nodeAccents: Record<NodeType, { border: string; icon: string }>
// Node surfaces
nodeCardBackground: string
nodeIconBackground: string
nodeLabelColor: string
nodeSubtextColor: string
// Status indicator dots
statusColors: Record<NodeStatus, string>
// Edges
edgeColors: Record<EdgeType, string>
edgeSelectedColor: string
edgeLabelBackground: string
edgeLabelColor: string
edgeLabelBorder: string
// Canvas
canvasBackground: string
canvasDotColor: string
// React Flow handles
handleBackground: string
handleBorder: string
// React Flow colorMode for built-in controls
reactFlowColorMode: 'dark' | 'light'
}
export interface ThemePreset {
id: ThemeId
label: string
description: string
colors: ThemeColors
}
export const THEMES: Record<ThemeId, ThemePreset> = {
default: {
id: 'default',
label: 'Default',
description: 'Dark futuristic — the original Homelable look',
colors: {
nodeAccents: {
isp: { border: '#00d4ff', icon: '#00d4ff' },
router: { border: '#00d4ff', icon: '#00d4ff' },
switch: { border: '#39d353', icon: '#39d353' },
server: { border: '#a855f7', icon: '#a855f7' },
proxmox: { border: '#ff6e00', icon: '#ff6e00' },
vm: { border: '#a855f7', icon: '#a855f7' },
lxc: { border: '#00d4ff', icon: '#00d4ff' },
nas: { border: '#39d353', icon: '#39d353' },
iot: { border: '#e3b341', icon: '#e3b341' },
ap: { border: '#00d4ff', icon: '#00d4ff' },
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
cpl: { border: '#e3b341', icon: '#e3b341' },
generic: { border: '#8b949e', icon: '#8b949e' },
groupRect:{ border: '#00d4ff', icon: '#00d4ff' },
},
nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22',
nodeLabelColor: '#e6edf3',
nodeSubtextColor: '#8b949e',
statusColors: {
online: '#39d353',
offline: '#f85149',
pending: '#e3b341',
unknown: '#8b949e',
},
edgeColors: {
ethernet: '#30363d',
wifi: '#00d4ff',
iot: '#e3b341',
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
edgeLabelColor: '#8b949e',
edgeLabelBorder: '#30363d',
canvasBackground: '#0d1117',
canvasDotColor: '#30363d',
handleBackground: '#30363d',
handleBorder: '#8b949e',
reactFlowColorMode: 'dark',
},
},
dark: {
id: 'dark',
label: 'Dark',
description: 'Pure black with maximum contrast',
colors: {
nodeAccents: {
isp: { border: '#22d3ee', icon: '#22d3ee' },
router: { border: '#22d3ee', icon: '#22d3ee' },
switch: { border: '#4ade80', icon: '#4ade80' },
server: { border: '#c084fc', icon: '#c084fc' },
proxmox: { border: '#fb923c', icon: '#fb923c' },
vm: { border: '#c084fc', icon: '#c084fc' },
lxc: { border: '#22d3ee', icon: '#22d3ee' },
nas: { border: '#4ade80', icon: '#4ade80' },
iot: { border: '#fbbf24', icon: '#fbbf24' },
ap: { border: '#22d3ee', icon: '#22d3ee' },
camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
generic: { border: '#94a3b8', icon: '#94a3b8' },
groupRect:{ border: '#22d3ee', icon: '#22d3ee' },
},
nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111',
nodeLabelColor: '#ffffff',
nodeSubtextColor: '#666666',
statusColors: {
online: '#4ade80',
offline: '#ef4444',
pending: '#fbbf24',
unknown: '#6b7280',
},
edgeColors: {
ethernet: '#1c1c1e',
wifi: '#22d3ee',
iot: '#fbbf24',
vlan: '#22d3ee',
virtual: '#6b7280',
cluster: '#fb923c',
},
edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111',
edgeLabelColor: '#666666',
edgeLabelBorder: '#1c1c1e',
canvasBackground: '#000000',
canvasDotColor: '#1a1a1a',
handleBackground: '#1c1c1e',
handleBorder: '#444444',
reactFlowColorMode: 'dark',
},
},
light: {
id: 'light',
label: 'Light',
description: 'Clean light theme with dark text',
colors: {
nodeAccents: {
isp: { border: '#0284c7', icon: '#0284c7' },
router: { border: '#0284c7', icon: '#0284c7' },
switch: { border: '#16a34a', icon: '#16a34a' },
server: { border: '#7c3aed', icon: '#7c3aed' },
proxmox: { border: '#ea580c', icon: '#ea580c' },
vm: { border: '#7c3aed', icon: '#7c3aed' },
lxc: { border: '#0284c7', icon: '#0284c7' },
nas: { border: '#16a34a', icon: '#16a34a' },
iot: { border: '#b45309', icon: '#b45309' },
ap: { border: '#0284c7', icon: '#0284c7' },
camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
cpl: { border: '#b45309', icon: '#b45309' },
generic: { border: '#6b7280', icon: '#6b7280' },
groupRect:{ border: '#0284c7', icon: '#0284c7' },
},
nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff',
nodeLabelColor: '#1f2328',
nodeSubtextColor: '#57606a',
statusColors: {
online: '#16a34a',
offline: '#dc2626',
pending: '#d97706',
unknown: '#6b7280',
},
edgeColors: {
ethernet: '#d0d7de',
wifi: '#0284c7',
iot: '#d97706',
vlan: '#0284c7',
virtual: '#9ca3af',
cluster: '#ea580c',
},
edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff',
edgeLabelColor: '#57606a',
edgeLabelBorder: '#d0d7de',
canvasBackground: '#f6f8fa',
canvasDotColor: '#d0d7de',
handleBackground: '#d0d7de',
handleBorder: '#9ca3af',
reactFlowColorMode: 'light',
},
},
neon: {
id: 'neon',
label: 'Neon',
description: 'Cyberpunk vibes with vivid glowing accents',
colors: {
nodeAccents: {
isp: { border: '#00ffff', icon: '#00ffff' },
router: { border: '#00ffff', icon: '#00ffff' },
switch: { border: '#00ff80', icon: '#00ff80' },
server: { border: '#ff00ff', icon: '#ff00ff' },
proxmox: { border: '#ff8800', icon: '#ff8800' },
vm: { border: '#ff00ff', icon: '#ff00ff' },
lxc: { border: '#00ffff', icon: '#00ffff' },
nas: { border: '#00ff80', icon: '#00ff80' },
iot: { border: '#ffff00', icon: '#ffff00' },
ap: { border: '#00ffff', icon: '#00ffff' },
camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
cpl: { border: '#ffff00', icon: '#ffff00' },
generic: { border: '#8888ff', icon: '#8888ff' },
groupRect:{ border: '#00ffff', icon: '#00ffff' },
},
nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a',
nodeLabelColor: '#ffffff',
nodeSubtextColor: '#8888cc',
statusColors: {
online: '#00ff80',
offline: '#ff0040',
pending: '#ffff00',
unknown: '#8888cc',
},
edgeColors: {
ethernet: '#1a1a3a',
wifi: '#00ffff',
iot: '#ffff00',
vlan: '#00ffff',
virtual: '#8888cc',
cluster: '#ff8800',
},
edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a',
edgeLabelColor: '#8888cc',
edgeLabelBorder: '#1a1a3a',
canvasBackground: '#05050f',
canvasDotColor: '#1a1a3a',
handleBackground: '#1a1a3a',
handleBorder: '#8888cc',
reactFlowColorMode: 'dark',
},
},
matrix: {
id: 'matrix',
label: 'Matrix',
description: 'Everything in terminal green',
colors: {
nodeAccents: {
isp: { border: '#00ff41', icon: '#00ff41' },
router: { border: '#00ff41', icon: '#00ff41' },
switch: { border: '#00cc33', icon: '#00cc33' },
server: { border: '#008822', icon: '#008822' },
proxmox: { border: '#33ff66', icon: '#33ff66' },
vm: { border: '#008822', icon: '#008822' },
lxc: { border: '#00ff41', icon: '#00ff41' },
nas: { border: '#00cc33', icon: '#00cc33' },
iot: { border: '#66ff33', icon: '#66ff33' },
ap: { border: '#00ff41', icon: '#00ff41' },
camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
cpl: { border: '#66ff33', icon: '#66ff33' },
generic: { border: '#006600', icon: '#006600' },
groupRect:{ border: '#00ff41', icon: '#00ff41' },
},
nodeCardBackground: '#001100',
nodeIconBackground: '#002200',
nodeLabelColor: '#00ff41',
nodeSubtextColor: '#006600',
statusColors: {
online: '#00ff41',
offline: '#ff0000',
pending: '#88ff00',
unknown: '#004400',
},
edgeColors: {
ethernet: '#003300',
wifi: '#00ff41',
iot: '#66ff33',
vlan: '#00cc33',
virtual: '#004400',
cluster: '#33ff66',
},
edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100',
edgeLabelColor: '#006600',
edgeLabelBorder: '#003300',
canvasBackground: '#000000',
canvasDotColor: '#002200',
handleBackground: '#003300',
handleBorder: '#006600',
reactFlowColorMode: 'dark',
},
},
}
// Ordered list for display in the modal
export const THEME_ORDER: ThemeId[] = ['default', 'dark', 'light', 'neon', 'matrix']