feat(mapper): add IP and port fields via node metadata

This commit is contained in:
2026-03-22 12:20:54 -05:00
parent a13c52d3e3
commit 0dcf5b3c8c
10 changed files with 241 additions and 42 deletions

View File

@@ -25,7 +25,8 @@ export interface NodeEditModalProps {
initialLabel: string;
initialColor?: string;
initialModuleId?: string | null;
onSaved: (updated: { label: string; color: string; moduleId: string | null }) => void;
initialMetadata?: string | null;
onSaved: (updated: { label: string; color: string; moduleId: string | null; metadata?: string }) => void;
}
export function NodeEditModal({
@@ -35,12 +36,15 @@ export function NodeEditModal({
initialLabel,
initialColor,
initialModuleId,
initialMetadata,
onSaved,
}: NodeEditModalProps) {
const { racks } = useRackStore();
const [label, setLabel] = useState(initialLabel);
const [color, setColor] = useState(initialColor ?? '#3b82f6');
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
const [ipAddress, setIpAddress] = useState('');
const [port, setPort] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
@@ -48,20 +52,50 @@ export function NodeEditModal({
setLabel(initialLabel);
setColor(initialColor ?? '#3b82f6');
setModuleId(initialModuleId ?? '');
if (initialMetadata) {
try {
const parsed = JSON.parse(initialMetadata);
setIpAddress(parsed.ipAddress || '');
setPort(parsed.port || '');
} catch {
setIpAddress('');
setPort('');
}
} else {
setIpAddress('');
setPort('');
}
}
}, [open, initialLabel, initialColor, initialModuleId]);
}, [open, initialLabel, initialColor, initialModuleId, initialMetadata]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!label.trim()) return;
setLoading(true);
try {
let metaObj: Record<string, unknown> = {};
if (initialMetadata) {
try {
metaObj = JSON.parse(initialMetadata);
} catch { /* ignore */ }
}
if (ipAddress.trim()) metaObj.ipAddress = ipAddress.trim();
else delete metaObj.ipAddress;
if (port.trim()) metaObj.port = port.trim();
else delete metaObj.port;
const metadataString = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : '';
await apiClient.nodes.update(nodeId, {
label: label.trim(),
color,
moduleId: moduleId || null,
metadata: metadataString,
});
onSaved({ label: label.trim(), color, moduleId: moduleId || null });
onSaved({ label: label.trim(), color, moduleId: moduleId || null, metadata: metadataString });
toast.success('Node updated');
onClose();
} catch (err) {
@@ -139,6 +173,30 @@ export function NodeEditModal({
</select>
</div>
{/* Logical Address */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="e.g. 10.0.0.5"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-mono"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Port</label>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
disabled={loading}
placeholder="e.g. 443"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-mono"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel

View File

@@ -136,6 +136,7 @@ type NodeEditState = {
label: string;
color?: string;
moduleId?: string | null;
metadata?: string | null;
} | null;
function ServiceMapperInner() {
@@ -277,7 +278,7 @@ function ServiceMapperInner() {
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault();
const d = node.data as { label?: string; color?: string; module?: { id: string } };
const d = node.data as { label?: string; color?: string; module?: { id: string }; metadata?: string };
setCtxMenu({
kind: 'node',
x: event.clientX,
@@ -302,12 +303,13 @@ function ServiceMapperInner() {
}, []);
const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => {
const d = node.data as { label?: string; color?: string; module?: { id: string } };
const d = node.data as { label?: string; color?: string; module?: { id: string }; metadata?: string };
setNodeEditState({
nodeId: node.id,
label: d.label ?? '',
color: d.color,
moduleId: d.module?.id ?? null,
metadata: d.metadata ?? null,
});
}, []);
@@ -430,12 +432,12 @@ function ServiceMapperInner() {
// ---- Node edit modal save ----
function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null }) {
function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null; metadata?: string }) {
if (!nodeEditState) return;
setNodes((nds) =>
nds.map((n) =>
n.id === nodeEditState.nodeId
? { ...n, data: { ...n.data, label: updated.label, color: updated.color } }
? { ...n, data: { ...n.data, label: updated.label, color: updated.color, metadata: updated.metadata } }
: n
)
);
@@ -457,11 +459,14 @@ function ServiceMapperInner() {
if (ctxMenu.kind === 'node') {
const { nodeId, label, color, moduleId } = ctxMenu;
const node = nodes.find(n => n.id === nodeId);
const metadata = node ? (node.data as any).metadata : null;
return [
{
label: 'Edit node',
icon: <Edit2 size={12} />,
onClick: () => setNodeEditState({ nodeId, label, color, moduleId }),
onClick: () => setNodeEditState({ nodeId, label, color, moduleId, metadata }),
},
{
label: 'Duplicate',
@@ -615,6 +620,7 @@ function ServiceMapperInner() {
initialLabel={nodeEditState.label}
initialColor={nodeEditState.color}
initialModuleId={nodeEditState.moduleId}
initialMetadata={nodeEditState.metadata}
onSaved={handleNodeEditSaved}
/>
)}

View File

@@ -1,20 +1,39 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Zap } from 'lucide-react';
export const ApiNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'API';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
const method = (data as { method?: string }).method;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-yellow-500 border-yellow-500' : 'border-yellow-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Zap size={13} className="text-yellow-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
{method && (
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
{method}
</span>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center gap-2">
<Zap size={13} className="text-yellow-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
{method && (
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
{method}
</span>
)}
</div>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />

View File

@@ -1,15 +1,34 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Database } from 'lucide-react';
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Database';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-teal-500 border-teal-500' : 'border-teal-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Database size={13} className="text-teal-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center gap-2">
<Database size={13} className="text-teal-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
</div>

View File

@@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { Module } from '../../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
@@ -16,6 +16,17 @@ export const DeviceNode = memo(({ data, selected }: NodeProps) => {
const colors = mod ? MODULE_TYPE_COLORS[mod.type] : null;
const meta = useMemo(() => {
try {
return nodeData.metadata ? JSON.parse(nodeData.metadata as string) : {};
} catch {
return {};
}
}, [nodeData.metadata]);
const ipToDisplay = meta.ipAddress || mod?.ipAddress;
const hasAddress = ipToDisplay || meta.port;
return (
<div
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
@@ -37,15 +48,24 @@ export const DeviceNode = memo(({ data, selected }: NodeProps) => {
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
</div>
{mod && (
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-1 mt-1">
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
{mod.ipAddress && (
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span>
{hasAddress && (
<span className="text-[10px] text-slate-400 font-mono ml-1 mt-0.5">
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
</span>
)}
</div>
)}
{!mod && (
<span className="text-[10px] text-slate-500">Unlinked device</span>
<div className="flex flex-col mt-1">
<span className="text-[10px] text-slate-500">Unlinked device</span>
{hasAddress && (
<span className="text-[10px] text-slate-400 font-mono">
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
</span>
)}
</div>
)}
</div>

View File

@@ -1,15 +1,34 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Cloud } from 'lucide-react';
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'External';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
return (
<div className={`min-w-[140px] bg-slate-800 border-2 border-dashed rounded-lg shadow-lg ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-500'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Cloud size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center gap-2">
<Cloud size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>

View File

@@ -1,15 +1,34 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Shield } from 'lucide-react';
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Firewall';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-red-500 border-red-500' : 'border-red-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Shield size={13} className="text-red-400 shrink-0" />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center gap-2">
<Shield size={13} className="text-red-400 shrink-0" />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
</div>

View File

@@ -1,15 +1,34 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Scale } from 'lucide-react';
export const LBNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Load Balancer';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-orange-500 border-orange-500' : 'border-orange-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Scale size={13} className="text-orange-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center gap-2">
<Scale size={13} className="text-orange-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
</div>

View File

@@ -1,10 +1,20 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Layers } from 'lucide-react';
export const ServiceNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Service';
const color = (data as { color?: string }).color ?? '#3b82f6';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
return (
<div
@@ -14,9 +24,18 @@ export const ServiceNode = memo(({ data, selected }: NodeProps) => {
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Layers size={13} style={{ color }} className="shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
<div className="px-3 py-2 flex flex-col gap-1">
<div className="flex items-center gap-2">
<Layers size={13} style={{ color }} className="shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>