feat(mapper): add IP and port fields via node metadata
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(git commit:*)",
|
||||
"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;
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/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 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
|
||||
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 }}
|
||||
>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user