Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8cadba17b | |||
| 5f7cb1bf11 | |||
| 3fb3bf016b | |||
| ba032a45af | |||
| 68b35a0c30 | |||
| 41cfccbd37 | |||
| 55a842cdad | |||
| 7074c5387b | |||
| 15f210470a | |||
| 271bbf2d01 | |||
| 8affcda09d | |||
| 92d505f78c | |||
| 16de7cd390 | |||
| 0fb091b12c | |||
| 1f1dfd807b | |||
| 1e72366d03 |
@@ -4,9 +4,21 @@ Homelable is a self-hosted infrastructure visualization solution. It provides a
|
|||||||
|
|
||||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
|
|
||||||
|
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
|
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="100%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start — Docker
|
## Quick Start — Docker
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
|
with suppress(Exception):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class Edge(Base):
|
|||||||
speed: Mapped[str | None] = mapped_column(String)
|
speed: Mapped[str | None] = mapped_column(String)
|
||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: Mapped[str | None] = mapped_column(String)
|
path_style: Mapped[str | None] = mapped_column(String)
|
||||||
|
animated: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_handle: Mapped[str | None] = mapped_column(String)
|
target_handle: Mapped[str | None] = mapped_column(String)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class EdgeSave(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class EdgeBase(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool = False
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ class EdgeUpdate(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
|
animated: bool | None = None
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 339 KiB |
+3
-2
@@ -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>
|
||||||
|
|||||||
@@ -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 |
+226
-37
@@ -2,6 +2,7 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { exportToPng } from '@/utils/export'
|
import { exportToPng } from '@/utils/export'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
@@ -14,8 +15,13 @@ 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 { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
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 +31,18 @@ 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, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = 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 [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = 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 +52,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,
|
||||||
@@ -76,18 +116,19 @@ export default function App() {
|
|||||||
speed: e.data?.speed ?? null,
|
speed: e.data?.speed ?? null,
|
||||||
custom_color: e.data?.custom_color ?? null,
|
custom_color: e.data?.custom_color ?? null,
|
||||||
path_style: e.data?.path_style ?? null,
|
path_style: e.data?.path_style ?? null,
|
||||||
|
animated: e.data?.animated ?? false,
|
||||||
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
// Normalize stub handle IDs: "top-t" / "bottom-t" are invisible target stubs;
|
||||||
// map them back to their canonical source handle ID so reload works correctly.
|
// map them back to their canonical source handle ID so reload works correctly.
|
||||||
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 +140,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 +163,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,27 +196,48 @@ 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
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
|
const undoRef = useRef(undo)
|
||||||
|
const redoRef = useRef(redo)
|
||||||
|
const copyRef = useRef(copySelectedNodes)
|
||||||
|
const pasteRef = useRef(pasteNodes)
|
||||||
|
useEffect(() => { undoRef.current = undo }, [undo])
|
||||||
|
useEffect(() => { redoRef.current = redo }, [redo])
|
||||||
|
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
||||||
|
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
const ctrl = e.ctrlKey || e.metaKey
|
||||||
e.preventDefault()
|
// Ignore shortcuts when typing in an input/textarea
|
||||||
handleSaveRef.current()
|
const tag = (e.target as HTMLElement).tagName
|
||||||
}
|
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable
|
||||||
|
|
||||||
|
if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||||
|
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||||
|
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
||||||
|
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
||||||
|
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||||
|
snapshotHistory()
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const isProxmox = data.type === 'proxmox'
|
const isProxmox = data.type === 'proxmox'
|
||||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||||
@@ -179,7 +256,61 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
addNode(newNode)
|
addNode(newNode)
|
||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
}, [addNode, nodes])
|
}, [addNode, nodes, snapshotHistory])
|
||||||
|
|
||||||
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
|
snapshotHistory()
|
||||||
|
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, snapshotHistory])
|
||||||
|
|
||||||
|
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
|
||||||
|
snapshotHistory()
|
||||||
|
deleteNode(editingGroupRectId)
|
||||||
|
setEditingGroupRectId(null)
|
||||||
|
}, [editingGroupRectId, deleteNode, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
const handleEditNode = useCallback((id: string) => {
|
const handleEditNode = useCallback((id: string) => {
|
||||||
setEditNodeId(id)
|
setEditNodeId(id)
|
||||||
@@ -187,6 +318,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
const handleUpdateNode = useCallback((data: Partial<NodeData>) => {
|
||||||
if (!editNodeId) return
|
if (!editNodeId) return
|
||||||
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||||
@@ -216,7 +348,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEditNodeId(null)
|
setEditNodeId(null)
|
||||||
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect])
|
}, [editNodeId, updateNode, setProxmoxContainerMode, nodes, edges, deleteEdge, onConnect, snapshotHistory])
|
||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
const handleAutoLayout = useCallback(() => {
|
||||||
const laid = applyDagreLayout(nodes, edges)
|
const laid = applyDagreLayout(nodes, edges)
|
||||||
@@ -224,6 +356,13 @@ export default function App() {
|
|||||||
toast.success('Canvas auto-arranged')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [nodes, edges, loadCanvas])
|
||||||
|
|
||||||
|
const handleExportMd = useCallback(async () => {
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
if (!md) { toast.error('No nodes to export'); return }
|
||||||
|
await navigator.clipboard.writeText(md)
|
||||||
|
toast.success('Markdown table copied to clipboard')
|
||||||
|
}, [nodes])
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const handleExport = useCallback(async () => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -241,6 +380,7 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
const handleEdgeConfirm = useCallback((edgeData: EdgeData) => {
|
||||||
if (!pendingConnection) return
|
if (!pendingConnection) return
|
||||||
|
snapshotHistory()
|
||||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
@@ -255,7 +395,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
}, [pendingConnection, onConnect, nodes, updateNode])
|
}, [pendingConnection, onConnect, nodes, updateNode, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
const handleEdgeDoubleClick = useCallback((edge: Edge<EdgeData>) => {
|
||||||
setEditEdgeId(edge.id)
|
setEditEdgeId(edge.id)
|
||||||
@@ -263,15 +403,17 @@ export default function App() {
|
|||||||
|
|
||||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
updateEdge(editEdgeId, data)
|
updateEdge(editEdgeId, data)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, updateEdge])
|
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||||
|
|
||||||
const handleEdgeDelete = useCallback(() => {
|
const handleEdgeDelete = useCallback(() => {
|
||||||
if (!editEdgeId) return
|
if (!editEdgeId) return
|
||||||
|
snapshotHistory()
|
||||||
deleteEdge(editEdgeId)
|
deleteEdge(editEdgeId)
|
||||||
setEditEdgeId(null)
|
setEditEdgeId(null)
|
||||||
}, [editEdgeId, deleteEdge])
|
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||||
|
|
||||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||||
@@ -284,6 +426,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,10 +436,15 @@ export default function App() {
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onAutoLayout={handleAutoLayout}
|
onAutoLayout={handleAutoLayout}
|
||||||
onExport={handleExport}
|
onExport={handleExport}
|
||||||
|
onChangeStyle={() => setThemeModalOpen(true)}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
onExportMd={handleExportMd}
|
||||||
/>
|
/>
|
||||||
<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">
|
||||||
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} />
|
<CanvasContainer onConnect={handleEdgeConnect} onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDragStart={snapshotHistory} />
|
||||||
</div>
|
</div>
|
||||||
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
{selectedNodeId && <DetailPanel onEdit={handleEditNode} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,6 +500,47 @@ 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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SearchModal open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||||
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(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'
|
||||||
@@ -18,15 +20,19 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
onConnect?: (connection: Connection) => void
|
onConnect?: (connection: Connection) => void
|
||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
|
onNodeDragStart?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) {
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
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 +45,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}
|
||||||
@@ -51,12 +56,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
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 +71,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>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import {
|
|||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
getSmoothStepPath,
|
getSmoothStepPath,
|
||||||
|
useStore,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
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,42 +18,83 @@ 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> = {
|
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||||
ethernet: { stroke: '#30363d', strokeWidth: 2 },
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
wifi: { stroke: '#00d4ff', strokeWidth: 1.5, strokeDasharray: '6 3' },
|
const theme = THEMES[activeTheme]
|
||||||
iot: { stroke: '#e3b341', strokeWidth: 1.5, strokeDasharray: '2 4' },
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
vlan: { strokeWidth: 2.5 },
|
const targetType = useStore((s) => s.nodeLookup.get(target)?.type)
|
||||||
virtual: { stroke: '#8b949e', strokeWidth: 1, strokeDasharray: '4 4' },
|
const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox'
|
||||||
cluster: { stroke: '#ff6e00', strokeWidth: 2.5, strokeDasharray: '8 3' },
|
|
||||||
}
|
|
||||||
|
|
||||||
export function HomelableEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
|
||||||
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)` } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animated dot: slightly brighter + thicker than the base edge, travels source→target
|
||||||
|
const dotColor = customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : edgeColors[edgeType as keyof typeof edgeColors] as string)
|
||||||
|
const dotWidth = ((style.strokeWidth as number ?? 2) + 1.5) * 2
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={style} />
|
<BaseEdge id={id} path={edgePath} style={style} />
|
||||||
|
{data?.animated && (
|
||||||
|
<path
|
||||||
|
d={edgePath}
|
||||||
|
fill="none"
|
||||||
|
stroke={dotColor}
|
||||||
|
strokeWidth={dotWidth}
|
||||||
|
strokeDasharray="20 10000"
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ pointerEvents: 'none' }}
|
||||||
|
>
|
||||||
|
{isBidirectional ? (
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
values="-10000;0;-10000"
|
||||||
|
keyTimes="0;0.5;1"
|
||||||
|
dur="20s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<animate
|
||||||
|
attributeName="stroke-dashoffset"
|
||||||
|
from="-10000"
|
||||||
|
to="0"
|
||||||
|
dur="10s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</path>
|
||||||
|
)}
|
||||||
{data?.label && (
|
{data?.label && (
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
<div
|
<div
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
|
const [animated, setAnimated] = useState(initial?.animated ?? false)
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
|
animated: animated || undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -113,6 +115,22 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Flow Animation</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAnimated((a) => !a)}
|
||||||
|
className="relative w-9 h-5 rounded-full transition-colors focus:outline-none shrink-0"
|
||||||
|
style={{ background: animated ? '#00d4ff' : '#30363d' }}
|
||||||
|
aria-pressed={animated}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform"
|
||||||
|
style={{ transform: animated ? 'translateX(16px)' : 'translateX(0)' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||||
|
|||||||
@@ -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,84 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useReactFlow } from '@xyflow/react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
interface SearchModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchModal({ open, onClose }: SearchModalProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
|
const { fitView } = useReactFlow()
|
||||||
|
|
||||||
|
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
const results = q.length === 0 ? [] : searchable.filter((n) =>
|
||||||
|
n.data.label?.toLowerCase().includes(q) ||
|
||||||
|
n.data.ip?.toLowerCase().includes(q) ||
|
||||||
|
n.data.hostname?.toLowerCase().includes(q)
|
||||||
|
).slice(0, 8)
|
||||||
|
|
||||||
|
const handleSelect = useCallback((nodeId: string) => {
|
||||||
|
setSelectedNode(nodeId)
|
||||||
|
fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 })
|
||||||
|
onClose()
|
||||||
|
setQuery('')
|
||||||
|
}, [fitView, setSelectedNode, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-md"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
|
||||||
|
<Search size={16} className="text-muted-foreground shrink-0" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search nodes by label, IP, hostname…"
|
||||||
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||||
|
if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{results.length > 0 && (
|
||||||
|
<ul className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{results.map((node) => (
|
||||||
|
<li
|
||||||
|
key={node.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||||
|
onClick={() => handleSelect(node.id)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
|
||||||
|
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
|
||||||
|
{node.data.ip && (
|
||||||
|
<span className="text-xs font-mono text-muted-foreground shrink-0">{node.data.ip}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.length > 0 && results.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q.length === 0 && (
|
||||||
|
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
{
|
||||||
|
group: 'Canvas',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Ctrl', 'S'], description: 'Save canvas' },
|
||||||
|
{ keys: ['Ctrl', 'Z'], description: 'Undo' },
|
||||||
|
{ keys: ['Ctrl', 'Y'], description: 'Redo' },
|
||||||
|
{ keys: ['Ctrl', 'K'], description: 'Search nodes' },
|
||||||
|
{ keys: ['?'], description: 'Show this help' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Nodes',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Ctrl', 'C'], description: 'Copy selected nodes' },
|
||||||
|
{ keys: ['Ctrl', 'V'], description: 'Paste nodes' },
|
||||||
|
{ keys: ['Del'], description: 'Delete selected node/edge' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Navigation',
|
||||||
|
items: [
|
||||||
|
{ keys: ['Scroll'], description: 'Zoom in / out' },
|
||||||
|
{ keys: ['Space', '+', 'Drag'], description: 'Pan canvas' },
|
||||||
|
{ keys: ['Ctrl', 'Shift', 'F'], description: 'Fit view' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ShortcutsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShortcutsModal({ open, onClose }: ShortcutsModalProps) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#161b22] border border-border rounded-lg shadow-2xl w-full max-w-sm"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">Keyboard Shortcuts</h2>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" onClick={onClose}>
|
||||||
|
<X size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{SHORTCUTS.map((group) => (
|
||||||
|
<div key={group.group}>
|
||||||
|
<p className="text-xs text-[#00d4ff] font-semibold mb-2 uppercase tracking-wide">
|
||||||
|
{group.group}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<div key={item.description} className="flex items-center justify-between gap-4">
|
||||||
|
<span className="text-sm text-muted-foreground">{item.description}</span>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{item.keys.map((k, i) => (
|
||||||
|
k === '+' ? (
|
||||||
|
<span key={i} className="text-xs text-muted-foreground">+</span>
|
||||||
|
) : (
|
||||||
|
<kbd key={k} className="text-[11px] text-foreground border border-border rounded px-1.5 py-0.5 font-mono bg-[#0d1117]">
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ShortcutsModal } from '../ShortcutsModal'
|
||||||
|
|
||||||
|
describe('ShortcutsModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ShortcutsModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(container.firstChild).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders shortcut groups when open', () => {
|
||||||
|
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Keyboard Shortcuts')).toBeDefined()
|
||||||
|
expect(screen.getByText('Canvas')).toBeDefined()
|
||||||
|
expect(screen.getByText('Nodes')).toBeDefined()
|
||||||
|
expect(screen.getByText('Navigation')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows key shortcuts in kbd elements', () => {
|
||||||
|
render(<ShortcutsModal open={true} onClose={vi.fn()} />)
|
||||||
|
expect(screen.getAllByText('Ctrl').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when backdrop clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const { container } = render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||||
|
fireEvent.click(container.firstChild as HTMLElement)
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when X button clicked', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<ShortcutsModal open={true} onClose={onClose} />)
|
||||||
|
const buttons = screen.getAllByRole('button')
|
||||||
|
fireEvent.click(buttons[0])
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 */}
|
||||||
@@ -47,30 +76,110 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
<div className="flex flex-col gap-3 px-4 py-3 text-sm">
|
||||||
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
<DetailRow label="Type" value={NODE_TYPE_LABELS[data.type]} />
|
||||||
{data.hostname && <DetailRow label="Hostname" value={data.hostname} mono />}
|
{data.hostname && (
|
||||||
|
<div className="flex justify-between gap-2 items-baseline">
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">Hostname</span>
|
||||||
|
<a
|
||||||
|
href={`http://${data.hostname}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1"
|
||||||
|
title={data.hostname}
|
||||||
|
>
|
||||||
|
{data.hostname}
|
||||||
|
<ExternalLink size={10} className="shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
{data.ip && <DetailRow label="IP Address" value={data.ip} mono />}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{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 +191,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 +225,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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,25 +1,60 @@
|
|||||||
import { Save, LayoutDashboard, Download } from 'lucide-react'
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2 } 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
|
||||||
|
onUndo: () => void
|
||||||
|
onRedo: () => void
|
||||||
|
onShortcuts: () => void
|
||||||
|
onExportMd: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = 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 disabled:opacity-30"
|
||||||
|
onClick={onUndo}
|
||||||
|
disabled={past.length === 0}
|
||||||
|
title="Undo (Ctrl+Z)"
|
||||||
|
>
|
||||||
|
<Undo2 size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm" variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||||
|
onClick={onRedo}
|
||||||
|
disabled={future.length === 0}
|
||||||
|
title="Redo (Ctrl+Y)"
|
||||||
|
>
|
||||||
|
<Redo2 size={14} />
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-4 bg-border mx-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={onExport}>
|
<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} title="Export as PNG">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
|
<Table2 size={14} /> MD
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
|
<HelpCircle size={14} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-1
@@ -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;
|
||||||
@@ -115,3 +115,13 @@
|
|||||||
.font-mono {
|
.font-mono {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Edge flow animation — dot traveling from source to target */
|
||||||
|
@keyframes flow-dot {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: -10000; }
|
||||||
|
}
|
||||||
|
.edge-flow-dot {
|
||||||
|
animation: flow-dot 2.5s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ describe('canvasStore', () => {
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
editingGroupRectId: null,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,6 +194,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' }
|
||||||
@@ -199,4 +237,95 @@ describe('canvasStore', () => {
|
|||||||
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
const childIdx = nodes.findIndex((n) => n.id === 'c1')
|
||||||
expect(parentIdx).toBeLessThan(childIdx)
|
expect(parentIdx).toBeLessThan(childIdx)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- History (undo/redo) ---
|
||||||
|
|
||||||
|
it('snapshotHistory pushes current state to past and clears future', () => {
|
||||||
|
const { addNode, snapshotHistory } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
const { past, future } = useCanvasStore.getState()
|
||||||
|
expect(past).toHaveLength(1)
|
||||||
|
expect(past[0].nodes).toHaveLength(1)
|
||||||
|
expect(future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo restores previous state and moves current to future', () => {
|
||||||
|
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
const { nodes, past, future } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(1)
|
||||||
|
expect(nodes[0].id).toBe('n1')
|
||||||
|
expect(past).toHaveLength(0)
|
||||||
|
expect(future).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redo re-applies undone state', () => {
|
||||||
|
const { addNode, snapshotHistory, undo, redo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
redo()
|
||||||
|
const { nodes, future } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(2)
|
||||||
|
expect(future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('undo does nothing when past is empty', () => {
|
||||||
|
const { addNode, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
undo()
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('snapshotHistory clears future (new branch)', () => {
|
||||||
|
const { addNode, snapshotHistory, undo } = useCanvasStore.getState()
|
||||||
|
addNode(makeNode('n1'))
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n2'))
|
||||||
|
undo()
|
||||||
|
// now take a new action
|
||||||
|
snapshotHistory()
|
||||||
|
addNode(makeNode('n3'))
|
||||||
|
expect(useCanvasStore.getState().future).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Clipboard (copy/paste) ---
|
||||||
|
|
||||||
|
it('copySelectedNodes stores only selected nodes', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ ...makeNode('a'), selected: true },
|
||||||
|
{ ...makeNode('b'), selected: false },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
const { clipboard } = useCanvasStore.getState()
|
||||||
|
expect(clipboard).toHaveLength(1)
|
||||||
|
expect(clipboard[0].id).toBe('a')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
||||||
|
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes).toHaveLength(2)
|
||||||
|
const pasted = nodes.find((n) => n.id !== 'src')!
|
||||||
|
expect(pasted).toBeDefined()
|
||||||
|
expect(pasted.position.x).toBe(150)
|
||||||
|
expect(pasted.position.y).toBe(150)
|
||||||
|
expect(pasted.selected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||||
|
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
edges: Edge<EdgeData>[]
|
edges: Edge<EdgeData>[]
|
||||||
@@ -18,6 +20,18 @@ interface CanvasState {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
scanEventTs: number
|
scanEventTs: number
|
||||||
|
|
||||||
|
// History
|
||||||
|
past: HistoryEntry[]
|
||||||
|
future: HistoryEntry[]
|
||||||
|
snapshotHistory: () => void
|
||||||
|
undo: () => void
|
||||||
|
redo: () => void
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
clipboard: Node<NodeData>[]
|
||||||
|
copySelectedNodes: () => void
|
||||||
|
pasteNodes: () => void
|
||||||
|
|
||||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
onConnect: (connection: Connection) => void
|
onConnect: (connection: Connection) => void
|
||||||
@@ -28,9 +42,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,8 +58,71 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
editingGroupRectId: null,
|
||||||
|
hideIp: false,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
clipboard: [],
|
||||||
|
|
||||||
|
snapshotHistory: () =>
|
||||||
|
set((state) => ({
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
})),
|
||||||
|
|
||||||
|
undo: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.past.length === 0) return state
|
||||||
|
const previous = state.past[state.past.length - 1]
|
||||||
|
return {
|
||||||
|
nodes: previous.nodes,
|
||||||
|
edges: previous.edges,
|
||||||
|
past: state.past.slice(0, -1),
|
||||||
|
future: [{ nodes: state.nodes, edges: state.edges }, ...state.future.slice(0, 49)],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
redo: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.future.length === 0) return state
|
||||||
|
const next = state.future[0]
|
||||||
|
return {
|
||||||
|
nodes: next.nodes,
|
||||||
|
edges: next.edges,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: state.future.slice(1),
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
copySelectedNodes: () =>
|
||||||
|
set((state) => ({
|
||||||
|
clipboard: state.nodes.filter((n) => n.selected),
|
||||||
|
})),
|
||||||
|
|
||||||
|
pasteNodes: () =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.clipboard.length === 0) return state
|
||||||
|
const newNodes = state.clipboard.map((n) => ({
|
||||||
|
...n,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||||
|
selected: false,
|
||||||
|
parentId: undefined,
|
||||||
|
extent: undefined,
|
||||||
|
data: { ...n.data, parent_id: undefined },
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
nodes: [...state.nodes, ...newNodes],
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
onNodesChange: (changes) =>
|
onNodesChange: (changes) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
nodes: applyNodeChanges(changes, state.nodes),
|
nodes: applyNodeChanges(changes, state.nodes),
|
||||||
@@ -141,10 +224,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)
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +81,7 @@ export interface EdgeData extends Record<string, unknown> {
|
|||||||
speed?: string
|
speed?: string
|
||||||
custom_color?: string
|
custom_color?: string
|
||||||
path_style?: EdgePathStyle
|
path_style?: EdgePathStyle
|
||||||
|
animated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||||
@@ -76,6 +100,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> = {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { generateMarkdownTable } from '../exportMarkdown'
|
||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const makeNode = (overrides: Partial<NodeData> = {}, id = '1'): Node<NodeData> => ({
|
||||||
|
id,
|
||||||
|
type: overrides.type ?? 'server',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: { label: 'Test', type: 'server', status: 'online', services: [], ...overrides },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateMarkdownTable', () => {
|
||||||
|
it('returns empty string for empty node list', () => {
|
||||||
|
expect(generateMarkdownTable([])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes groupRect nodes', () => {
|
||||||
|
const nodes = [makeNode({ type: 'groupRect', label: 'Zone' })]
|
||||||
|
expect(generateMarkdownTable(nodes)).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates header + separator + row', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Router', type: 'router', ip: '192.168.1.1', status: 'online' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
const lines = md.split('\n')
|
||||||
|
expect(lines[0]).toContain('Label')
|
||||||
|
expect(lines[0]).toContain('IP')
|
||||||
|
expect(lines[1]).toContain('---')
|
||||||
|
expect(lines[2]).toContain('Router')
|
||||||
|
expect(lines[2]).toContain('192.168.1.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses — for missing fields', () => {
|
||||||
|
const nodes = [makeNode({ label: 'Node', type: 'generic', ip: undefined, hostname: undefined })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('—')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists services as name:port pairs', () => {
|
||||||
|
const nodes = [makeNode({
|
||||||
|
label: 'Server',
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'nginx' }, { port: 443, protocol: 'tcp', service_name: 'https' }],
|
||||||
|
})]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('nginx:80')
|
||||||
|
expect(md).toContain('https:443')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('escapes pipe characters in cell values', () => {
|
||||||
|
const nodes = [makeNode({ label: 'A|B' })]
|
||||||
|
const md = generateMarkdownTable(nodes)
|
||||||
|
expect(md).toContain('A\\|B')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates one row per non-groupRect node', () => {
|
||||||
|
const nodes = [
|
||||||
|
makeNode({ type: 'server', label: 'A' }, '1'),
|
||||||
|
makeNode({ type: 'router', label: 'B' }, '2'),
|
||||||
|
makeNode({ type: 'groupRect', label: 'Zone' }, '3'),
|
||||||
|
]
|
||||||
|
const lines = generateMarkdownTable(nodes).split('\n')
|
||||||
|
// header + separator + 2 data rows
|
||||||
|
expect(lines).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Node } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const EMPTY = '—'
|
||||||
|
|
||||||
|
function cell(v: string | null | undefined): string {
|
||||||
|
if (!v) return EMPTY
|
||||||
|
// Escape pipe chars so they don't break the table
|
||||||
|
return v.replace(/\|/g, '\\|')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||||
|
const rows = nodes
|
||||||
|
.filter((n) => n.data.type !== 'groupRect')
|
||||||
|
.map((n) => {
|
||||||
|
const d = n.data
|
||||||
|
const services = d.services?.length
|
||||||
|
? d.services.map((s) => `${s.service_name}:${s.port}`).join(', ')
|
||||||
|
: EMPTY
|
||||||
|
return [
|
||||||
|
cell(d.label),
|
||||||
|
cell(d.type),
|
||||||
|
cell(d.ip),
|
||||||
|
cell(d.hostname),
|
||||||
|
cell(d.status),
|
||||||
|
services,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (rows.length === 0) return ''
|
||||||
|
|
||||||
|
const headers = ['Label', 'Type', 'IP', 'Hostname', 'Status', 'Services']
|
||||||
|
const separator = headers.map(() => '---')
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`| ${headers.join(' | ')} |`,
|
||||||
|
`| ${separator.join(' | ')} |`,
|
||||||
|
...rows.map((r) => `| ${r.join(' | ')} |`),
|
||||||
|
]
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
+117
-14
@@ -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 } }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
Reference in New Issue
Block a user