Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f1dfd807b | |||
| 1e72366d03 |
+150
-21
@@ -14,6 +14,7 @@ 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 { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { canvasApi } from '@/api/client'
|
import { canvasApi } from '@/api/client'
|
||||||
@@ -25,13 +26,14 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, nodes, edges } = useCanvasStore()
|
const { loadCanvas, markSaved, selectedNodeId, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
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)
|
||||||
@@ -46,26 +48,55 @@ export default function App() {
|
|||||||
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,
|
||||||
@@ -121,6 +152,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,
|
||||||
@@ -181,6 +226,58 @@ export default function App() {
|
|||||||
toast.success(`Added "${data.label}"`)
|
toast.success(`Added "${data.label}"`)
|
||||||
}, [addNode, nodes])
|
}, [addNode, nodes])
|
||||||
|
|
||||||
|
const handleAddGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const newNode: Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 200, y: 200 },
|
||||||
|
data: {
|
||||||
|
label: data.label,
|
||||||
|
type: 'groupRect',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: {
|
||||||
|
border: data.border_color,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_position: data.text_position,
|
||||||
|
font: data.font,
|
||||||
|
z_order: data.z_order,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
zIndex: data.z_order - 10,
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
}, [addNode])
|
||||||
|
|
||||||
|
const handleUpdateGroupRect = useCallback((data: GroupRectFormData) => {
|
||||||
|
if (!editingGroupRectId) return
|
||||||
|
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
||||||
|
updateNode(editingGroupRectId, {
|
||||||
|
label: data.label,
|
||||||
|
custom_colors: {
|
||||||
|
...existing?.data.custom_colors,
|
||||||
|
border: data.border_color,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_position: data.text_position,
|
||||||
|
font: data.font,
|
||||||
|
z_order: data.z_order,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setNodeZIndex(editingGroupRectId, data.z_order - 10)
|
||||||
|
setEditingGroupRectId(null)
|
||||||
|
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId])
|
||||||
|
|
||||||
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
|
if (!editingGroupRectId) return
|
||||||
|
deleteNode(editingGroupRectId)
|
||||||
|
setEditingGroupRectId(null)
|
||||||
|
}, [editingGroupRectId, deleteNode, setEditingGroupRectId])
|
||||||
|
|
||||||
const handleEditNode = useCallback((id: string) => {
|
const handleEditNode = useCallback((id: string) => {
|
||||||
setEditNodeId(id)
|
setEditNodeId(id)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -284,6 +381,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}
|
||||||
@@ -352,6 +450,37 @@ 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"
|
||||||
|
/>
|
||||||
|
|
||||||
<Toaster theme="dark" position="bottom-right" />
|
<Toaster theme="dark" position="bottom-right" />
|
||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick }:
|
|||||||
snapGrid={[16, 16]}
|
snapGrid={[16, 16]}
|
||||||
fitView
|
fitView
|
||||||
colorMode="dark"
|
colorMode="dark"
|
||||||
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
isValidConnection={(connection) => connection.source !== connection.target}
|
isValidConnection={(connection) => connection.source !== connection.target}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, GenericNode } from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
@@ -17,4 +18,5 @@ export const nodeTypes = {
|
|||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
|
groupRect: GroupRectNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import type { TextPosition } from '@/types'
|
||||||
|
|
||||||
|
export interface GroupRectFormData {
|
||||||
|
label: string
|
||||||
|
font: string
|
||||||
|
text_color: string
|
||||||
|
text_position: TextPosition
|
||||||
|
border_color: string
|
||||||
|
background_color: string
|
||||||
|
z_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: GroupRectFormData = {
|
||||||
|
label: '',
|
||||||
|
font: 'inter',
|
||||||
|
text_color: '#e6edf3',
|
||||||
|
text_position: 'top-left',
|
||||||
|
border_color: '#00d4ff',
|
||||||
|
background_color: '#00d4ff0d',
|
||||||
|
z_order: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||||
|
{ value: 'mono', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
|
||||||
|
{ value: 'top-left', label: '↖' },
|
||||||
|
{ value: 'top-center', label: '↑' },
|
||||||
|
{ value: 'top-right', label: '↗' },
|
||||||
|
{ value: 'middle-left', label: '←' },
|
||||||
|
{ value: 'center', label: '·' },
|
||||||
|
{ value: 'middle-right', label: '→' },
|
||||||
|
{ value: 'bottom-left', label: '↙' },
|
||||||
|
{ value: 'bottom-center', label: '↓' },
|
||||||
|
{ value: 'bottom-right', label: '↘' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface GroupRectModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: GroupRectFormData) => void
|
||||||
|
onDelete?: () => void
|
||||||
|
initial?: Partial<GroupRectFormData>
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
|
||||||
|
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
|
||||||
|
|
||||||
|
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(form)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ key: 'text_color' as const, label: 'Text' },
|
||||||
|
{ key: 'border_color' as const, label: 'Border' },
|
||||||
|
{ key: 'background_color' as const, label: 'Background' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
|
{/* Label */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||||
|
<Input
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => set('label', e.target.value)}
|
||||||
|
placeholder="Zone name…"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
|
<Select value={form.font} onValueChange={(v) => set('font', v)}>
|
||||||
|
<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) => set('z_order', Number(v))}>
|
||||||
|
<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,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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
import { X, Edit, Trash2, ExternalLink } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { X, Edit, Trash2, ExternalLink, Plus } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo } from '@/types'
|
||||||
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
onEdit: (id: string) => void
|
onEdit: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode } = useCanvasStore()
|
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
|
||||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||||
|
|
||||||
if (!node) return null
|
const [addingService, setAddingService] = useState(false)
|
||||||
|
const [newSvc, setNewSvc] = useState<{ port: string; protocol: 'tcp' | 'udp'; service_name: string }>({
|
||||||
|
port: '',
|
||||||
|
protocol: 'tcp',
|
||||||
|
service_name: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!node || node.data.type === 'groupRect') return null
|
||||||
|
|
||||||
const { data } = node
|
const { data } = node
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
const statusColor = STATUS_COLORS[data.status]
|
||||||
|
const host = data.ip ?? data.hostname
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
@@ -22,6 +33,24 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddService = () => {
|
||||||
|
const port = parseInt(newSvc.port, 10)
|
||||||
|
if (!newSvc.service_name.trim() || isNaN(port) || port < 1 || port > 65535) return
|
||||||
|
const svc: ServiceInfo = {
|
||||||
|
port,
|
||||||
|
protocol: newSvc.protocol,
|
||||||
|
service_name: newSvc.service_name.trim(),
|
||||||
|
}
|
||||||
|
updateNode(node.id, { services: [...(data.services ?? []), svc] })
|
||||||
|
setNewSvc({ port: '', protocol: 'tcp', service_name: '' })
|
||||||
|
setAddingService(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveService = (index: number) => {
|
||||||
|
const updated = data.services.filter((_, i) => i !== index)
|
||||||
|
updateNode(node.id, { services: updated })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -53,24 +82,90 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && (
|
{data.last_seen && (
|
||||||
<DetailRow
|
<DetailRow label="Last Seen" value={new Date(data.last_seen).toLocaleString()} />
|
||||||
label="Last Seen"
|
|
||||||
value={new Date(data.last_seen).toLocaleString()}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
{data.services.length > 0 && (
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-xs text-muted-foreground mb-2">Services ({data.services.length})</div>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Services{data.services.length > 0 ? ` (${data.services.length})` : ''}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setAddingService((v) => !v)}
|
||||||
|
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={10} /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add service form */}
|
||||||
|
{addingService && (
|
||||||
|
<div className="flex flex-col gap-1.5 mb-2 p-2 rounded-md bg-[#0d1117] border border-[#30363d]">
|
||||||
|
<Input
|
||||||
|
value={newSvc.service_name}
|
||||||
|
onChange={(e) => setNewSvc((s) => ({ ...s, service_name: e.target.value }))}
|
||||||
|
placeholder="Service name"
|
||||||
|
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newSvc.port}
|
||||||
|
onChange={(e) => setNewSvc((s) => ({ ...s, port: e.target.value }))}
|
||||||
|
placeholder="Port"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
className="bg-[#21262d] border-[#30363d] font-mono text-xs h-7 w-20 shrink-0"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={newSvc.protocol}
|
||||||
|
onChange={(e) => setNewSvc((s) => ({ ...s, protocol: e.target.value as 'tcp' | 'udp' }))}
|
||||||
|
className="flex-1 bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground"
|
||||||
|
>
|
||||||
|
<option value="tcp">tcp</option>
|
||||||
|
<option value="udp">udp</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="flex-1 h-6 text-[10px] bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={handleAddService}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-[10px]"
|
||||||
|
onClick={() => setAddingService(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.services.length > 0 && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{data.services.map((svc) => (
|
{data.services.map((svc, i) => (
|
||||||
<ServiceBadge key={`${svc.port}-${svc.protocol}`} svc={svc} ip={data.ip} />
|
<ServiceBadge
|
||||||
|
key={`${svc.port}-${svc.protocol}-${i}`}
|
||||||
|
svc={svc}
|
||||||
|
host={host}
|
||||||
|
onRemove={() => handleRemoveService(i)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{data.services.length === 0 && !addingService && (
|
||||||
|
<p className="text-[10px] text-muted-foreground/50">No services — click Add to register one.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{data.notes && (
|
{data.notes && (
|
||||||
@@ -82,20 +177,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||||
<Button
|
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1 gap-1.5"
|
|
||||||
onClick={() => onEdit(node.id)}
|
|
||||||
>
|
|
||||||
<Edit size={14} /> Edit
|
<Edit size={14} /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button size="sm" variant="destructive" className="gap-1.5" onClick={handleDelete}>
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
className="gap-1.5"
|
|
||||||
onClick={handleDelete}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,39 +211,33 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
remote: '#8b949e',
|
remote: '#8b949e',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServiceUrl(svc: ServiceInfo, ip?: string): string | null {
|
function ServiceBadge({ svc, host, onRemove }: { svc: ServiceInfo; host?: string; onRemove: () => void }) {
|
||||||
if (!ip) return null
|
const url = getServiceUrl(svc, host)
|
||||||
const name = svc.service_name.toLowerCase()
|
|
||||||
const isHttps = name.includes('https') || name.includes('ssl') || svc.port === 443 || svc.port === 8443
|
|
||||||
const isHttp = name.includes('http') || [80, 8080, 8000, 3000, 8888, 9000, 8090, 7080].includes(svc.port)
|
|
||||||
if (isHttps) return `https://${ip}:${svc.port}`
|
|
||||||
if (isHttp) return `http://${ip}:${svc.port}`
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceBadge({ svc, ip }: { svc: ServiceInfo; ip?: string }) {
|
|
||||||
const url = getServiceUrl(svc, ip)
|
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
|
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: '#21262d',
|
background: '#21262d',
|
||||||
borderColor: '#30363d',
|
borderColor: '#30363d',
|
||||||
...(url ? { cursor: 'pointer' } : {}),
|
cursor: url ? 'pointer' : 'default',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
className="shrink-0 w-1.5 h-1.5 rounded-full"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
<span className="font-medium truncate" style={{ color }}>{svc.service_name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
<span className="font-mono text-[#8b949e]">{svc.port}/{svc.protocol}</span>
|
||||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
||||||
|
title="Remove service"
|
||||||
|
>
|
||||||
|
<X size={10} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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 { Network, Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square } from 'lucide-react'
|
||||||
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 +30,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 } = 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 {
|
||||||
@@ -110,7 +112,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,6 +128,7 @@ 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
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,7 @@ describe('canvasStore', () => {
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
editingGroupRectId: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -190,6 +191,40 @@ describe('canvasStore', () => {
|
|||||||
expect(updatedChild?.extent).toBeUndefined()
|
expect(updatedChild?.extent).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('setEditingGroupRectId sets and clears the editing id', () => {
|
||||||
|
useCanvasStore.getState().setEditingGroupRectId('rect-1')
|
||||||
|
expect(useCanvasStore.getState().editingGroupRectId).toBe('rect-1')
|
||||||
|
useCanvasStore.getState().setEditingGroupRectId(null)
|
||||||
|
expect(useCanvasStore.getState().editingGroupRectId).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setNodeZIndex updates the node zIndex and marks unsaved', () => {
|
||||||
|
useCanvasStore.getState().addNode(makeNode('n1'))
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
useCanvasStore.getState().setNodeZIndex('n1', -5)
|
||||||
|
const node = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(node?.zIndex).toBe(-5)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addNode with groupRect type preserves zIndex and dimensions', () => {
|
||||||
|
const rectNode: Node<NodeData> = {
|
||||||
|
id: 'rect-1',
|
||||||
|
type: 'groupRect',
|
||||||
|
position: { x: 100, y: 100 },
|
||||||
|
data: { label: 'Zone A', type: 'groupRect', status: 'unknown', services: [] },
|
||||||
|
width: 360,
|
||||||
|
height: 240,
|
||||||
|
zIndex: -9,
|
||||||
|
}
|
||||||
|
useCanvasStore.getState().addNode(rectNode)
|
||||||
|
const stored = useCanvasStore.getState().nodes.find((n) => n.id === 'rect-1')
|
||||||
|
expect(stored?.type).toBe('groupRect')
|
||||||
|
expect(stored?.zIndex).toBe(-9)
|
||||||
|
expect(stored?.width).toBe(360)
|
||||||
|
expect(stored?.height).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
it('loadCanvas sorts parents before children', () => {
|
it('loadCanvas sorts parents before children', () => {
|
||||||
const parent = makeNode('p1')
|
const parent = makeNode('p1')
|
||||||
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
const child: Node<NodeData> = { ...makeNode('c1', { parent_id: 'p1' }), parentId: 'p1', extent: 'parent' }
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ 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
|
||||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
@@ -38,6 +41,7 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
edges: [],
|
edges: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
selectedNodeId: null,
|
selectedNodeId: null,
|
||||||
|
editingGroupRectId: null,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
|
|
||||||
onNodesChange: (changes) =>
|
onNodesChange: (changes) =>
|
||||||
@@ -141,6 +145,14 @@ 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 }),
|
||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ export type NodeType =
|
|||||||
| 'computer'
|
| 'computer'
|
||||||
| 'cpl'
|
| 'cpl'
|
||||||
| 'generic'
|
| 'generic'
|
||||||
|
| 'groupRect'
|
||||||
|
|
||||||
|
export type TextPosition =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'middle-left'
|
||||||
|
| 'center'
|
||||||
|
| 'middle-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right'
|
||||||
|
|
||||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
||||||
|
|
||||||
@@ -45,7 +57,18 @@ export interface NodeData extends Record<string, unknown> {
|
|||||||
notes?: string
|
notes?: string
|
||||||
parent_id?: string
|
parent_id?: string
|
||||||
container_mode?: boolean
|
container_mode?: boolean
|
||||||
custom_colors?: { border?: string; background?: string; icon?: string }
|
custom_colors?: {
|
||||||
|
border?: string
|
||||||
|
background?: string
|
||||||
|
icon?: string
|
||||||
|
// Group rectangle extras (type === 'groupRect')
|
||||||
|
text_color?: string
|
||||||
|
text_position?: TextPosition
|
||||||
|
font?: string
|
||||||
|
z_order?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
custom_icon?: string
|
custom_icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +99,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
computer: 'Computer',
|
computer: 'Computer',
|
||||||
cpl: 'CPL / Powerline',
|
cpl: 'CPL / Powerline',
|
||||||
generic: 'Generic Device',
|
generic: 'Generic Device',
|
||||||
|
groupRect: 'Group Rectangle',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user