Merge branch 'Pouzor:main' into bug/bezier-label
This commit is contained in:
@@ -66,7 +66,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
minWidth={140}
|
||||
minHeight={50}
|
||||
lineStyle={{ borderColor: 'transparent' }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
@@ -76,6 +76,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
|
||||
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||
<div
|
||||
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
{/* Main row */}
|
||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||
{/* Icon */}
|
||||
@@ -121,7 +128,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
return (
|
||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -139,7 +146,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||
<Cpu size={9} className="shrink-0" />
|
||||
{data.cpu_model && (
|
||||
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
||||
<span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
|
||||
)}
|
||||
{data.cpu_count != null && (
|
||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||
@@ -166,13 +173,6 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
|
||||
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { BaseNode } from './BaseNode'
|
||||
@@ -13,6 +15,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const { data, selected } = props
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const theme = THEMES[activeTheme]
|
||||
const colors = resolveNodeColors(data, activeTheme)
|
||||
|
||||
@@ -53,7 +56,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
minHeight={160}
|
||||
isVisible={selected}
|
||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
|
||||
/>
|
||||
|
||||
{/* Group border */}
|
||||
@@ -71,7 +74,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
||||
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
|
||||
style={{
|
||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||
@@ -93,18 +96,19 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
>
|
||||
{data.label}
|
||||
</span>
|
||||
{data.ip && (
|
||||
{data.ip && splitIps(data.ip).map((ip) => (
|
||||
<span
|
||||
key={ip}
|
||||
className="font-mono text-[9px] truncate"
|
||||
style={{ color: theme.colors.nodeSubtextColor }}
|
||||
>
|
||||
{data.ip}
|
||||
{hideIp ? maskIp(ip) : ip}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||
className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: statusColor }}
|
||||
title={data.status}
|
||||
/>
|
||||
@@ -125,7 +129,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon size={9} className="shrink-0" />}
|
||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -22,5 +22,6 @@ export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const DockerNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerNode, GenericNode } from './index'
|
||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
@@ -18,7 +18,8 @@ export const nodeTypes = {
|
||||
printer: PrinterNode,
|
||||
computer: ComputerNode,
|
||||
cpl: CplNode,
|
||||
docker: DockerNode,
|
||||
docker_host: DockerHostNode,
|
||||
docker_container: DockerContainerNode,
|
||||
generic: GenericNode,
|
||||
groupRect: GroupRectNode,
|
||||
group: GroupNode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement, Fragment, useState } from 'react'
|
||||
import { Fragment, createElement, useState } from 'react'
|
||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -11,12 +11,13 @@ import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||
|
||||
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||
none: 'None',
|
||||
@@ -37,7 +38,7 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
||||
status: 'unknown',
|
||||
check_method: 'ping',
|
||||
services: [],
|
||||
container_mode: true,
|
||||
container_mode: false,
|
||||
custom_colors: undefined,
|
||||
custom_icon: undefined,
|
||||
}
|
||||
@@ -48,12 +49,12 @@ interface NodeModalProps {
|
||||
onSubmit: (data: Partial<NodeData>) => void
|
||||
initial?: Partial<NodeData>
|
||||
title?: string
|
||||
proxmoxNodes?: { id: string; label: string }[]
|
||||
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
|
||||
}
|
||||
|
||||
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
|
||||
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
|
||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||
// initial value is enough - no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
||||
const [iconSearch, setIconSearch] = useState('')
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
@@ -69,10 +70,19 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
return
|
||||
}
|
||||
setLabelError(false)
|
||||
onSubmit(form)
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
onSubmit({
|
||||
...form,
|
||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
const filteredParentNodes = form.type === 'docker_container'
|
||||
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
||||
: parentContainerNodes
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
||||
@@ -92,7 +102,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
{NODE_TYPE_GROUPS.map((group, i) => (
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator key={`sep-${group.label}`} className="bg-[#30363d]" />}
|
||||
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
|
||||
{group.label}
|
||||
@@ -143,7 +153,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline icon picker — full width, shown below the type+icon row */}
|
||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||
{iconPickerOpen && (
|
||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||
<Input
|
||||
@@ -253,20 +263,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parent Proxmox (VM / LXC only) */}
|
||||
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
|
||||
{/* Parent container */}
|
||||
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
|
||||
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||
<Select
|
||||
value={form.parent_id ?? 'none'}
|
||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
||||
<SelectValue placeholder="None (standalone)" />
|
||||
<SelectValue placeholder="None (standalone)">
|
||||
{form.parent_id
|
||||
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
||||
: 'None (standalone)'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
||||
{proxmoxNodes.map((n) => (
|
||||
{filteredParentNodes.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -274,24 +288,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Container mode (proxmox only) */}
|
||||
{form.type === 'proxmox' && (
|
||||
{/* Container mode */}
|
||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
|
||||
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.container_mode}
|
||||
onClick={() => set('container_mode', !form.container_mode)}
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
|
||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
|
||||
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
|
||||
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
||||
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -353,10 +367,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="1" className="text-sm">1 — center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 — left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 — left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 — evenly spaced</SelectItem>
|
||||
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
||||
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
||||
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
||||
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -390,4 +404,4 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -234,15 +234,18 @@ describe('NodeModal', () => {
|
||||
expect(screen.queryByTitle('Router')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Container mode (proxmox only) ─────────────────────────────────────
|
||||
// ── Container mode ─────────────────────────────────────────────────────
|
||||
|
||||
it('shows Container Mode toggle for proxmox type', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'proxmox' } })
|
||||
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
|
||||
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
|
||||
|
||||
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.getByText('Container Mode')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides Container Mode for non-proxmox types', () => {
|
||||
renderModal({ initial: BASE })
|
||||
it.each(nonContainerModeTypes)('hides Container Mode for %s type', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.queryByText('Container Mode')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -253,33 +256,48 @@ describe('NodeModal', () => {
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||
})
|
||||
|
||||
// ── Parent Proxmox (vm / lxc only) ───────────────────────────────────
|
||||
// ── Parent container ──────────────────────────────────────────────────
|
||||
|
||||
it('shows Parent Proxmox for vm with proxmoxNodes', () => {
|
||||
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
||||
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
||||
|
||||
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'vm' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
initial: { ...BASE, type },
|
||||
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('PVE-01')).toBeDefined()
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
expect(screen.getByText('Container 01')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows Parent Proxmox for lxc with proxmoxNodes', () => {
|
||||
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
|
||||
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
|
||||
renderModal({ initial: { ...BASE, type } })
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('docker_container shows only docker_host parents', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'lxc' },
|
||||
proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }],
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentContainerNodes: [
|
||||
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
|
||||
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
|
||||
],
|
||||
})
|
||||
expect(screen.getByText('Parent Proxmox')).toBeDefined()
|
||||
expect(screen.getByText('My Docker Host')).toBeDefined()
|
||||
expect(screen.queryByText('My Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for server type', () => {
|
||||
renderModal({ initial: BASE, proxmoxNodes: [{ id: 'px1', label: 'PVE-01' }] })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides Parent Proxmox for vm when no proxmoxNodes', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'vm' } })
|
||||
expect(screen.queryByText('Parent Proxmox')).toBeNull()
|
||||
it('docker_container hides Parent Container when no docker_host is available', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createElement, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
@@ -661,23 +662,87 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||
const portLabel = svc.port != null ? String(svc.port) : 'host'
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : null
|
||||
const inner = (
|
||||
<div className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors" style={{ background: '#21262d', borderColor: '#30363d', cursor: url ? 'pointer' : 'default' }}>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="font-medium truncate" style={{ color }} title={svc.service_name}>{svc.service_name}</span>
|
||||
{pathLabel && <span className="truncate text-[#8b949e]" title={pathLabel}>{pathLabel}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="font-mono text-[#8b949e]">{portLabel}/{svc.protocol}</span>
|
||||
{url && <ExternalLink size={10} className="text-muted-foreground" />}
|
||||
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }} className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5" title="Edit service"><Pencil size={10} /></button>
|
||||
<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>
|
||||
const hasPort = svc.port != null
|
||||
const portLabel = hasPort ? String(svc.port) : ''
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
|
||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||
>
|
||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium truncate min-w-0 flex-1"
|
||||
style={{ color }}
|
||||
title={svc.service_name}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{svc.service_name}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="font-medium truncate min-w-0 flex-1"
|
||||
style={{ color }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{pathLabel && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="truncate text-[#8b949e] max-w-[80px]"
|
||||
tabIndex={0}
|
||||
aria-label={pathLabel}
|
||||
>
|
||||
{pathLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{pathLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{hasPort && (
|
||||
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
|
||||
)}
|
||||
{url ? (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
|
||||
aria-label="Open service link"
|
||||
style={{ color: 'inherit' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={10} className="text-muted-foreground" />
|
||||
</a>
|
||||
) : (
|
||||
<span className="w-2.5 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
|
||||
title="Edit service"
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
||||
title="Remove service"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
if (url) return <a href={url} target="_blank" rel="noopener noreferrer" className="block hover:opacity-80 transition-opacity">{inner}</a>
|
||||
return inner
|
||||
}
|
||||
|
||||
@@ -445,4 +445,46 @@ describe('DetailPanel', () => {
|
||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ServiceBadge rendering', () => {
|
||||
it('renders service name and port/protocol label', () => {
|
||||
setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('nginx')).toBeDefined()
|
||||
expect(screen.getByText('8080/tcp')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders path label when path is set', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('/admin')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders no path text when path is empty', () => {
|
||||
setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.queryByText('/')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders port/protocol omitted when port is absent', () => {
|
||||
setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('health')).toBeDefined()
|
||||
expect(screen.queryByText(/\/tcp/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders service name as link when ip and port are set', () => {
|
||||
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: 'nginx' })
|
||||
expect(link.getAttribute('href')).toContain('192.168.1.10')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('renders service name as plain text when no url can be built', () => {
|
||||
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,64 +1,30 @@
|
||||
"use client"
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
}: React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
className={[
|
||||
'z-50 overflow-hidden rounded-md border border-[#30363d] bg-[#161b22] px-2 py-1 text-xs text-[#e6edf3] shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user