feat(mapper): add IP and port fields via node metadata
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(npm uninstall:*)",
|
"Bash(npm uninstall:*)",
|
||||||
"Bash(git add:*)"
|
"Bash(git add:*)",
|
||||||
|
"Bash(engine response\" error at migrate/startup time:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export interface NodeEditModalProps {
|
|||||||
initialLabel: string;
|
initialLabel: string;
|
||||||
initialColor?: string;
|
initialColor?: string;
|
||||||
initialModuleId?: string | null;
|
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({
|
export function NodeEditModal({
|
||||||
@@ -35,12 +36,15 @@ export function NodeEditModal({
|
|||||||
initialLabel,
|
initialLabel,
|
||||||
initialColor,
|
initialColor,
|
||||||
initialModuleId,
|
initialModuleId,
|
||||||
|
initialMetadata,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: NodeEditModalProps) {
|
}: NodeEditModalProps) {
|
||||||
const { racks } = useRackStore();
|
const { racks } = useRackStore();
|
||||||
const [label, setLabel] = useState(initialLabel);
|
const [label, setLabel] = useState(initialLabel);
|
||||||
const [color, setColor] = useState(initialColor ?? '#3b82f6');
|
const [color, setColor] = useState(initialColor ?? '#3b82f6');
|
||||||
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
|
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
|
||||||
|
const [ipAddress, setIpAddress] = useState('');
|
||||||
|
const [port, setPort] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,20 +52,50 @@ export function NodeEditModal({
|
|||||||
setLabel(initialLabel);
|
setLabel(initialLabel);
|
||||||
setColor(initialColor ?? '#3b82f6');
|
setColor(initialColor ?? '#3b82f6');
|
||||||
setModuleId(initialModuleId ?? '');
|
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) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!label.trim()) return;
|
if (!label.trim()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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, {
|
await apiClient.nodes.update(nodeId, {
|
||||||
label: label.trim(),
|
label: label.trim(),
|
||||||
color,
|
color,
|
||||||
moduleId: moduleId || null,
|
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');
|
toast.success('Node updated');
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -139,6 +173,30 @@ export function NodeEditModal({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
|
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ type NodeEditState = {
|
|||||||
label: string;
|
label: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
moduleId?: string | null;
|
moduleId?: string | null;
|
||||||
|
metadata?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
function ServiceMapperInner() {
|
function ServiceMapperInner() {
|
||||||
@@ -277,7 +278,7 @@ function ServiceMapperInner() {
|
|||||||
|
|
||||||
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
|
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
|
||||||
event.preventDefault();
|
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({
|
setCtxMenu({
|
||||||
kind: 'node',
|
kind: 'node',
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
@@ -302,12 +303,13 @@ function ServiceMapperInner() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
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({
|
setNodeEditState({
|
||||||
nodeId: node.id,
|
nodeId: node.id,
|
||||||
label: d.label ?? '',
|
label: d.label ?? '',
|
||||||
color: d.color,
|
color: d.color,
|
||||||
moduleId: d.module?.id ?? null,
|
moduleId: d.module?.id ?? null,
|
||||||
|
metadata: d.metadata ?? null,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -430,12 +432,12 @@ function ServiceMapperInner() {
|
|||||||
|
|
||||||
// ---- Node edit modal save ----
|
// ---- 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;
|
if (!nodeEditState) return;
|
||||||
setNodes((nds) =>
|
setNodes((nds) =>
|
||||||
nds.map((n) =>
|
nds.map((n) =>
|
||||||
n.id === nodeEditState.nodeId
|
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
|
: n
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -457,11 +459,14 @@ function ServiceMapperInner() {
|
|||||||
|
|
||||||
if (ctxMenu.kind === 'node') {
|
if (ctxMenu.kind === 'node') {
|
||||||
const { nodeId, label, color, moduleId } = ctxMenu;
|
const { nodeId, label, color, moduleId } = ctxMenu;
|
||||||
|
const node = nodes.find(n => n.id === nodeId);
|
||||||
|
const metadata = node ? (node.data as any).metadata : null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Edit node',
|
label: 'Edit node',
|
||||||
icon: <Edit2 size={12} />,
|
icon: <Edit2 size={12} />,
|
||||||
onClick: () => setNodeEditState({ nodeId, label, color, moduleId }),
|
onClick: () => setNodeEditState({ nodeId, label, color, moduleId, metadata }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Duplicate',
|
label: 'Duplicate',
|
||||||
@@ -615,6 +620,7 @@ function ServiceMapperInner() {
|
|||||||
initialLabel={nodeEditState.label}
|
initialLabel={nodeEditState.label}
|
||||||
initialColor={nodeEditState.color}
|
initialColor={nodeEditState.color}
|
||||||
initialModuleId={nodeEditState.moduleId}
|
initialModuleId={nodeEditState.moduleId}
|
||||||
|
initialMetadata={nodeEditState.metadata}
|
||||||
onSaved={handleNodeEditSaved}
|
onSaved={handleNodeEditSaved}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,20 +1,39 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Zap } from 'lucide-react';
|
import { Zap } from 'lucide-react';
|
||||||
|
|
||||||
export const ApiNode = memo(({ data, selected }: NodeProps) => {
|
export const ApiNode = memo(({ data, selected }: NodeProps) => {
|
||||||
const label = (data as { label?: string }).label ?? 'API';
|
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;
|
const method = (data as { method?: string }).method;
|
||||||
return (
|
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'}`}>
|
<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" />
|
<Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
|
||||||
<div className="px-3 py-2 flex items-center gap-2">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<Zap size={13} className="text-yellow-400 shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
|
<Zap size={13} className="text-yellow-400 shrink-0" />
|
||||||
{method && (
|
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
|
||||||
<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 && (
|
||||||
{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">
|
||||||
</span>
|
{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>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
|
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Database } from 'lucide-react';
|
import { Database } from 'lucide-react';
|
||||||
|
|
||||||
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
|
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
|
||||||
const label = (data as { label?: string }).label ?? 'Database';
|
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 (
|
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'}`}>
|
<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" />
|
<Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
|
||||||
<div className="px-3 py-2 flex items-center gap-2">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<Database size={13} className="text-teal-400 shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
<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>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
|
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import type { Module } from '../../../types';
|
import type { Module } from '../../../types';
|
||||||
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
|
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 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
|
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>
|
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{mod && (
|
{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>
|
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
|
||||||
{mod.ipAddress && (
|
{hasAddress && (
|
||||||
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span>
|
<span className="text-[10px] text-slate-400 font-mono ml-1 mt-0.5">
|
||||||
|
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!mod && (
|
{!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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Cloud } from 'lucide-react';
|
import { Cloud } from 'lucide-react';
|
||||||
|
|
||||||
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
|
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
|
||||||
const label = (data as { label?: string }).label ?? 'External';
|
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 (
|
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'}`}>
|
<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" />
|
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
|
||||||
<div className="px-3 py-2 flex items-center gap-2">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<Cloud size={13} className="text-slate-400 shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
|
<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>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
|
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Shield } from 'lucide-react';
|
import { Shield } from 'lucide-react';
|
||||||
|
|
||||||
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
|
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
|
||||||
const label = (data as { label?: string }).label ?? 'Firewall';
|
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 (
|
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'}`}>
|
<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" />
|
<Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
|
||||||
<div className="px-3 py-2 flex items-center gap-2">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<Shield size={13} className="text-red-400 shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
|
<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>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
|
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Scale } from 'lucide-react';
|
import { Scale } from 'lucide-react';
|
||||||
|
|
||||||
export const LBNode = memo(({ data, selected }: NodeProps) => {
|
export const LBNode = memo(({ data, selected }: NodeProps) => {
|
||||||
const label = (data as { label?: string }).label ?? 'Load Balancer';
|
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 (
|
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'}`}>
|
<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" />
|
<Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
|
||||||
<div className="px-3 py-2 flex items-center gap-2">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<Scale size={13} className="text-orange-400 shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
<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>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
|
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
||||||
import { Layers } from 'lucide-react';
|
import { Layers } from 'lucide-react';
|
||||||
|
|
||||||
@@ -6,6 +6,16 @@ export const ServiceNode = memo(({ data, selected }: NodeProps) => {
|
|||||||
const label = (data as { label?: string }).label ?? 'Service';
|
const label = (data as { label?: string }).label ?? 'Service';
|
||||||
const color = (data as { color?: string }).color ?? '#3b82f6';
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-[140px] bg-slate-800 rounded-lg shadow-lg border overflow-hidden ${
|
className={`min-w-[140px] bg-slate-800 rounded-lg shadow-lg border overflow-hidden ${
|
||||||
@@ -14,9 +24,18 @@ export const ServiceNode = memo(({ data, selected }: NodeProps) => {
|
|||||||
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
|
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
|
||||||
>
|
>
|
||||||
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
|
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
|
||||||
<div className="px-3 py-2 flex items-center gap-2">
|
<div className="px-3 py-2 flex flex-col gap-1">
|
||||||
<Layers size={13} style={{ color }} className="shrink-0" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
|
<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>
|
</div>
|
||||||
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
|
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user