From 3c74b3d54404f7107b67824384430d27f4fa2428 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Thu, 28 Aug 2025 15:41:16 +0200 Subject: [PATCH] feat: GraphNode and GraphEdge revisited + lasso selection --- flowsint-api/app/api/routes/sketches.py | 9 - flowsint-app/package.json | 1 + .../components/analyses/analysis-editor.tsx | 4 +- .../components/analyses/default_content.json | 235 ++++++++++++++++++ .../src/components/graphs/context-menu.tsx | 26 +- .../graphs/details-panel/details-panel.tsx | 6 +- .../graphs/details-panel/neighbors.tsx | 6 +- .../graphs/details-panel/relationships.tsx | 32 ++- .../src/components/graphs/filters.tsx | 90 +++---- .../src/components/graphs/graph-main.tsx | 3 +- .../src/components/graphs/graph-settings.tsx | 6 +- .../src/components/graphs/graph-viewer.tsx | 234 +++++++++-------- .../renderer/src/components/graphs/index.tsx | 22 +- .../renderer/src/components/graphs/lasso.tsx | 140 +++++++++++ .../components/graphs/launch-transform.tsx | 74 +++++- .../src/components/graphs/nodes-panel.tsx | 35 ++- .../graphs/selected-items-panel.tsx | 10 +- .../src/components/graphs/toolbar.tsx | 188 ++++++++------ .../src/components/table/nodes-view.tsx | 4 +- .../components/table/relationships-view.tsx | 4 +- .../src/components/xyflow/context-menu.tsx | 10 +- flowsint-app/src/renderer/src/lib/utils.ts | 23 +- .../src/stores/graph-controls-store.ts | 4 + .../src/renderer/src/stores/graph-store.ts | 161 +++++------- .../src/renderer/src/stores/wall-store.ts | 73 ------ flowsint-app/src/renderer/src/styles.css | 14 +- flowsint-app/src/renderer/src/types/graph.ts | 33 ++- flowsint-app/yarn.lock | 5 + .../flowsint_transforms/domain/to_history.py | 6 +- .../flowsint_transforms/domain/to_whois.py | 15 +- .../flowsint_transforms/email/to_domains.py | 6 +- .../individual/to_domains.py | 6 +- .../flowsint_transforms/individual/to_org.py | 7 +- .../organization/to_domains.py | 6 +- .../organization/to_infos.py | 27 +- flowsint-types/src/flowsint_types/__init__.py | 4 +- flowsint-types/src/flowsint_types/address.py | 2 +- .../src/flowsint_types/individual.py | 12 +- .../src/flowsint_types/organization.py | 10 +- 39 files changed, 974 insertions(+), 579 deletions(-) create mode 100644 flowsint-app/src/renderer/src/components/analyses/default_content.json create mode 100644 flowsint-app/src/renderer/src/components/graphs/lasso.tsx delete mode 100644 flowsint-app/src/renderer/src/stores/wall-store.ts diff --git a/flowsint-api/app/api/routes/sketches.py b/flowsint-api/app/api/routes/sketches.py index 71e7dd6..e3712a4 100644 --- a/flowsint-api/app/api/routes/sketches.py +++ b/flowsint-api/app/api/routes/sketches.py @@ -151,15 +151,8 @@ async def get_sketch_nodes( nodes = [ { "id": str(record["id"]), - "labels": record["labels"], "data": record["data"], "label": record["data"].get("label", "Node"), - "type": "custom", - "caption": record["data"].get("label", "Node"), - "position": { - "x": random.random() * 1000, - "y": random.random() * 1000, - }, "idx": idx, } for idx, record in enumerate(nodes_result) @@ -168,11 +161,9 @@ async def get_sketch_nodes( rels = [ { "id": str(record["id"]), - "type": "custom", "source": str(record["source"]), "target": str(record["target"]), "data": record["data"], - "caption": record["type"], "label": record["type"], } for record in rels_result diff --git a/flowsint-app/package.json b/flowsint-app/package.json index 7105db4..bcea71c 100644 --- a/flowsint-app/package.json +++ b/flowsint-app/package.json @@ -107,6 +107,7 @@ "lucide-react": "^0.511.0", "marked": "^15.0.12", "next-themes": "^0.4.6", + "perfect-freehand": "^1.2.2", "react-day-picker": "8.10.1", "react-force-graph-2d": "^1.27.1", "react-force-graph-3d": "^1.26.1", diff --git a/flowsint-app/src/renderer/src/components/analyses/analysis-editor.tsx b/flowsint-app/src/renderer/src/components/analyses/analysis-editor.tsx index 9c2a53f..d71b69a 100644 --- a/flowsint-app/src/renderer/src/components/analyses/analysis-editor.tsx +++ b/flowsint-app/src/renderer/src/components/analyses/analysis-editor.tsx @@ -12,6 +12,7 @@ import { useConfirm } from "../use-confirm-dialog" import { Editor } from "@tiptap/core" import { Link, useParams } from "@tanstack/react-router" import { useLayoutStore } from "@/stores/layout-store" +import default_content from './default_content.json' import { Popover, PopoverContent, @@ -79,7 +80,6 @@ export const AnalysisEditor = ({ const [editor, setEditor] = useState(undefined) const [isEditingTitle, setIsEditingTitle] = useState(false) const [saveStatus, setSaveStatus] = useState<"saved" | "saving" | "unsaved">("saved") - // Debounced save function const debouncedSave = useCallback( debounce(() => { @@ -119,7 +119,7 @@ export const AnalysisEditor = ({ const newAnalysis: Partial = { title: "Untitled Analysis", investigation_id: investigationId, - content: {}, + content: default_content, } const res = await analysisService.create(JSON.stringify(newAnalysis)) return res diff --git a/flowsint-app/src/renderer/src/components/analyses/default_content.json b/flowsint-app/src/renderer/src/components/analyses/default_content.json new file mode 100644 index 0000000..89501d1 --- /dev/null +++ b/flowsint-app/src/renderer/src/components/analyses/default_content.json @@ -0,0 +1,235 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { + "level": 2 + }, + "content": [ + { + "type": "text", + "text": "Title of your investigation" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Include a clear title, your organization’s name, and the date." + } + ] + }, + { + "type": "heading", + "attrs": { + "level": 3 + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + }, + { + "type": "bold" + } + ], + "text": "Executive Summary" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "A snapshot of key findings and recommendations." + } + ] + }, + { + "type": "heading", + "attrs": { + "level": 3 + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + }, + { + "type": "bold" + } + ], + "text": "Introduction" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Define the scope and objectives." + } + ] + }, + { + "type": "heading", + "attrs": { + "level": 4 + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + }, + { + "type": "bold" + } + ], + "text": "Main Body" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + } + ], + "text": "Dive into the findings, broken into logical sections." + } + ] + }, + { + "type": "heading", + "attrs": { + "level": 4 + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + }, + { + "type": "bold" + } + ], + "text": "Analysis" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Highlight patterns, anomalies, and actionable insights." + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + }, + { + "type": "bold" + } + ], + "text": "Conclusion and Recommendations" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "text": "Wrap up with a clear summary and next steps." + } + ] + }, + { + "type": "heading", + "attrs": { + "level": 4 + }, + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + }, + { + "type": "bold" + } + ], + "text": "Appendices" + } + ] + }, + { + "type": "paragraph", + "content": [ + { + "type": "text", + "marks": [ + { + "type": "textStyle", + "attrs": { + "color": "" + } + } + ], + "text": "Include raw data or supporting documents if " + }, + { + "type": "text", + "text": "needed." + } + ] + } + ] +} \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/components/graphs/context-menu.tsx b/flowsint-app/src/renderer/src/components/graphs/context-menu.tsx index 37e6330..d390116 100644 --- a/flowsint-app/src/renderer/src/components/graphs/context-menu.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/context-menu.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { transformService } from '@/api/transfrom-service'; import { flowService } from '@/api/flow-service'; import { useQuery } from '@tanstack/react-query'; @@ -7,13 +7,12 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { FileCode2, Search, Info, Star, Zap } from 'lucide-react'; -import { Transform, Flow } from '@/types'; -import { GraphNode } from '@/stores/graph-store'; +import { FileCode2, Search, Info, Zap } from 'lucide-react'; +import { Transform, Flow, GraphNode } from '@/types'; import { useLaunchFlow } from '@/hooks/use-launch-flow'; import { useLaunchTransform } from '@/hooks/use-launch-transform'; import { useParams } from '@tanstack/react-router'; -import { capitalizeFirstLetter, cn } from '@/lib/utils'; +import { capitalizeFirstLetter } from '@/lib/utils'; import NodeActions from '@/components/graphs/node-actions'; import BaseContextMenu from '@/components/xyflow/context-menu'; @@ -93,7 +92,6 @@ export default function ContextMenu({ return ( { - const [favorite, seFavorite] = useState(isFavorite); - - const handleClick = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - seFavorite(!favorite); - }, [favorite]); - - return ( - - ) -}) - const InfoButton = ({ description }: { description?: string }) => { if (!description) return null; diff --git a/flowsint-app/src/renderer/src/components/graphs/details-panel/details-panel.tsx b/flowsint-app/src/renderer/src/components/graphs/details-panel/details-panel.tsx index 8d5bc2c..c3b7318 100644 --- a/flowsint-app/src/renderer/src/components/graphs/details-panel/details-panel.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/details-panel/details-panel.tsx @@ -4,13 +4,13 @@ import { CopyButton } from "@/components/copy" import { Check, Rocket, X, MousePointer } from "lucide-react" import LaunchFlow from "../launch-transform" import NodeActions from "../node-actions" -import { GraphNode } from "@/stores/graph-store" import { Button } from "../../ui/button" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip" import { useParams } from "@tanstack/react-router" import NeighborsGraph from "./neighbors" import Relationships from "./relationships" import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "../../ui/resizable" +import { GraphNode } from "@/types" const DetailsPanel = memo(({ node }: { node: GraphNode | null }) => { const { id: sketchId } = useParams({ strict: false }) @@ -67,7 +67,7 @@ const DetailsPanel = memo(({ node }: { node: GraphNode | null }) => { {node.data?.description && (
@@ -77,7 +77,7 @@ const DetailsPanel = memo(({ node }: { node: GraphNode | null }) => { -
+
diff --git a/flowsint-app/src/renderer/src/components/graphs/details-panel/neighbors.tsx b/flowsint-app/src/renderer/src/components/graphs/details-panel/neighbors.tsx index e477b39..d2b58cf 100644 --- a/flowsint-app/src/renderer/src/components/graphs/details-panel/neighbors.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/details-panel/neighbors.tsx @@ -2,16 +2,17 @@ import { sketchService } from "@/api/sketch-service"; import { useQuery } from "@tanstack/react-query"; import ForceGraphViewer from "../graph-viewer"; import Loader from "@/components/loader"; -import { memo } from "react"; +import { memo, useRef } from "react"; const NeighborsGraph = memo(({ sketchId, nodeId }: { sketchId: string, nodeId: string }) => { + const containerRef = useRef(null) const { data: neighborsData, isLoading } = useQuery({ queryKey: ['neighbors', sketchId, nodeId], queryFn: () => sketchService.getNodeNeighbors(sketchId, nodeId), }); return ( -
+
{isLoading ?
: <>
@@ -27,7 +28,6 @@ const NeighborsGraph = memo(({ sketchId, nodeId }: { sketchId: string, nodeId: s showLabels={true} showIcons={true} backgroundColor="transparent" - onGraphRef={() => { }} instanceId="neighbors" /> } diff --git a/flowsint-app/src/renderer/src/components/graphs/details-panel/relationships.tsx b/flowsint-app/src/renderer/src/components/graphs/details-panel/relationships.tsx index 9f31daf..91e1c5f 100644 --- a/flowsint-app/src/renderer/src/components/graphs/details-panel/relationships.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/details-panel/relationships.tsx @@ -2,11 +2,12 @@ import { sketchService } from '@/api/sketch-service'; import Loader from '@/components/loader'; import { TypeBadge } from '@/components/type-badge'; import { Badge } from '@/components/ui/badge'; -import { GraphEdge, GraphNode, useGraphStore } from '@/stores/graph-store' +import { useGraphStore } from '@/stores/graph-store' import { useQuery } from '@tanstack/react-query'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowRight } from 'lucide-react'; import { memo, useCallback, useRef } from 'react'; +import { GraphEdge, GraphNode } from "@/types" type Relation = { source: GraphNode, @@ -67,12 +68,20 @@ const Relationships = memo(({ sketchId, nodeId }: { sketchId: string, nodeId: st }} className="mb-1 px-3" > - - - - {rel.edge.label} - - + +
+
+ +
+ +
+ {rel.edge.label} +
+ +
+ +
+
); @@ -91,8 +100,13 @@ const RelationshipItem = memo(({ node }: { node: GraphNode }) => { setCurrentNode(node) }, [setCurrentNode]) return ( - ) }) \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/components/graphs/filters.tsx b/flowsint-app/src/renderer/src/components/graphs/filters.tsx index 41828b1..1604a75 100644 --- a/flowsint-app/src/renderer/src/components/graphs/filters.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/filters.tsx @@ -1,69 +1,41 @@ -import { useCallback } from 'react'; import { useGraphStore } from '@/stores/graph-store'; -import { useActionItems } from '@/hooks/use-action-items'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '../ui/dropdown-menu'; -import { Button } from '../ui/button'; -import { XIcon } from 'lucide-react'; -import { cn, getAllNodeTypes } from '@/lib/utils'; -import { Badge } from '../ui/badge'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Checkbox } from '../ui/checkbox'; +import { Separator } from '../ui/separator'; const Filters = ({ children }: { children: React.ReactNode }) => { const filters = useGraphStore(s => s.filters); - const setFilters = useGraphStore(s => s.setFilters); - const { actionItems } = useActionItems() - - const clearFilters = useCallback(() => { - setFilters(null) - }, [setFilters, filters]) - - const toggleFilter = useCallback((type: string | null) => { - if (type === null) { - clearFilters() - } else { - const currentTypeFilters = filters || [] - const isChecked = currentTypeFilters.includes(type) - const newTypeFilters = isChecked ? currentTypeFilters.filter((t: string) => t !== type) : [...currentTypeFilters, type.toLowerCase()] - setFilters(newTypeFilters) - } - }, [setFilters, filters, clearFilters]) + const toggleTypeFilter = useGraphStore(s => s.toggleTypeFilter); return ( - -
- -
- {children} + + +
+ {children} +
+
+ +
+
+

Filters

- -
- - - {filters?.length ? ( - - {filters?.length} filter(s) - - - ) : ( - "filters" - )} - - - toggleFilter(null)}> - All - - {getAllNodeTypes(actionItems || []).map((type) => ( - toggleFilter(type)} - > - {type || "unknown"} - - ))} - - + +

Filter by entity type

+ {filters.types.length === 0 ?

No filter to display.

: +
    + {filters.types.map((filter) => ( +
  • +
    toggleTypeFilter(filter)} />{filter.type}
    +
  • + ))} +
} +
+ + ); }; diff --git a/flowsint-app/src/renderer/src/components/graphs/graph-main.tsx b/flowsint-app/src/renderer/src/components/graphs/graph-main.tsx index f56e178..18f3e6d 100644 --- a/flowsint-app/src/renderer/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/graph-main.tsx @@ -14,7 +14,6 @@ const GraphMain = () => { const containerRef = useRef(null) const [menu, setMenu] = React.useState(null) - const handleNodeClick = useCallback((node: any, event: MouseEvent) => { const isMultiSelect = event.ctrlKey || event.shiftKey; toggleNodeSelection(node, isMultiSelect) @@ -50,7 +49,6 @@ const GraphMain = () => { const handleGraphRef = useCallback((ref: any) => { graphRef.current = ref }, []) - return (
{ showLabels={true} showIcons={true} onGraphRef={handleGraphRef} + allowLasso /> {menu && }
diff --git a/flowsint-app/src/renderer/src/components/graphs/graph-settings.tsx b/flowsint-app/src/renderer/src/components/graphs/graph-settings.tsx index 6112510..c852793 100644 --- a/flowsint-app/src/renderer/src/components/graphs/graph-settings.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/graph-settings.tsx @@ -10,7 +10,7 @@ import { Slider } from "@/components/ui/slider" import { Label } from '../ui/label' import { memo, useCallback } from 'react' import { Button } from '../ui/button' -import { type Setting } from '@/types' +import { type ForceGraphSetting } from '@/types' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' const GraphSettings = () => { @@ -51,7 +51,7 @@ const GraphSettings = () => { Update the settings of your graph. Changes apply in real-time. - + {Object.entries(categories).map(([categoryName, settingKeys]) => ( @@ -89,7 +89,7 @@ const GraphSettings = () => { export default GraphSettings type SettingItemProps = { - setting: Setting + setting: ForceGraphSetting label: string updateSetting: (key: string, value: number) => void } diff --git a/flowsint-app/src/renderer/src/components/graphs/graph-viewer.tsx b/flowsint-app/src/renderer/src/components/graphs/graph-viewer.tsx index 9b2f5e3..3c5a932 100644 --- a/flowsint-app/src/renderer/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/graph-viewer.tsx @@ -1,7 +1,7 @@ import { getDagreLayoutedElements } from '@/lib/utils'; import { useGraphControls } from '@/stores/graph-controls-store'; import { useGraphSettingsStore } from '@/stores/graph-settings-store'; -import { GraphNode, GraphEdge, useGraphStore } from '@/stores/graph-store'; +import { useGraphStore } from '@/stores/graph-store'; import { ItemType, useNodesDisplaySettings } from '@/stores/node-display-settings'; import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'; import ForceGraph2D from 'react-force-graph-2d'; @@ -9,6 +9,8 @@ import { Button } from '../ui/button'; import { useTheme } from '@/components/theme-provider' import { GRAPH_COLORS } from '../flows/scanner-data'; import { Share2, Type } from 'lucide-react'; +import Lasso from './lasso'; +import { GraphNode, GraphEdge } from '@/types'; function truncateText(text: string, limit: number = 16) { if (text.length <= limit) @@ -19,8 +21,7 @@ function truncateText(text: string, limit: number = 16) { interface GraphViewerProps { nodes: GraphNode[]; edges: GraphEdge[]; - width?: number; - height?: number; + // Remove width and height props - component will handle its own sizing nodeColors?: Record; nodeSizes?: Record; onNodeClick?: (node: GraphNode, event: MouseEvent) => void; @@ -33,6 +34,7 @@ interface GraphViewerProps { style?: React.CSSProperties; onGraphRef?: (ref: any) => void; instanceId?: string; // Add instanceId prop for instance-specific actions + allowLasso?: boolean } const CONSTANTS = { @@ -40,7 +42,7 @@ const CONSTANTS = { NODE_DEFAULT_SIZE: 10, NODE_LABEL_FONT_SIZE: 3.5, LABEL_FONT_SIZE: 2.5, - NODE_FONT_SIZE: 5, + NODE_FONT_SIZE: 3.5, LABEL_NODE_MARGIN: 18, PADDING_RATIO: 0.2, HALF_PI: Math.PI / 2, @@ -74,17 +76,14 @@ const imageLoadPromises = new Map>(); // Preload icon images const preloadImage = (iconType: string): Promise => { const cacheKey = iconType; - // Return cached image if available if (imageCache.has(cacheKey)) { return Promise.resolve(imageCache.get(cacheKey)!); } - // Return existing promise if already loading if (imageLoadPromises.has(cacheKey)) { return imageLoadPromises.get(cacheKey)!; } - // Create new loading promise const promise = new Promise((resolve, reject) => { const img = new Image(); @@ -99,7 +98,6 @@ const preloadImage = (iconType: string): Promise => { }; img.src = `/icons/${iconType}.svg`; }); - imageLoadPromises.set(cacheKey, promise); return promise; }; @@ -107,8 +105,7 @@ const preloadImage = (iconType: string): Promise => { const GraphViewer: React.FC = ({ nodes, edges, - width, - height, + // Remove width and height from destructuring onNodeClick, onNodeRightClick, onBackgroundClick, @@ -118,19 +115,20 @@ const GraphViewer: React.FC = ({ className = '', style, onGraphRef, - instanceId + instanceId, + allowLasso = false }) => { const [currentZoom, setCurrentZoom] = useState(1); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const isLassoActive = useGraphControls(s => s.isLassoActive) // Hover highlighting state const [highlightNodes, setHighlightNodes] = useState>(new Set()); const [highlightLinks, setHighlightLinks] = useState>(new Set()); const [hoverNode, setHoverNode] = useState(null); // Store references - const containerRef = useRef(null); const graphRef = useRef(); + const containerRef = useRef(null); const isGraphReadyRef = useRef(false); const lastRenderTimeRef = useRef(0); const renderThrottleRef = useRef(null); @@ -141,9 +139,29 @@ const GraphViewer: React.FC = ({ const settings = useGraphSettingsStore(s => s.settings); const view = useGraphControls(s => s.view); const setActions = useGraphControls(s => s.setActions); + const currentNode = useGraphStore(s => s.currentNode) + const selectedNodes = useGraphStore(s => s.selectedNodes) const { theme } = useTheme(); const setOpenMainDialog = useGraphStore(state => state.setOpenMainDialog); + const graph2ScreenCoords = useCallback((node: GraphNode) => { + return graphRef.current.graph2ScreenCoords(node.x, node.y); + }, [graphRef.current]) + + const isCurrent = useCallback( + (nodeId: string) => { + return currentNode?.id === nodeId + }, + [currentNode], + ) + + const isSelected = useCallback( + (nodeId: string) => { + return selectedNodes.some((node) => node.id === nodeId) + }, + [selectedNodes], + ) + // Preload icons when nodes change useEffect(() => { if (showIcons) { @@ -156,22 +174,41 @@ const GraphViewer: React.FC = ({ // Optimized graph initialization callback const initializeGraph = useCallback((graphInstance: any) => { - if (!graphInstance || isGraphReadyRef.current) return; + if (!graphInstance) return; + + // Check if the graph instance has the required methods + if (typeof graphInstance.zoom !== 'function' || typeof graphInstance.zoomToFit !== 'function') { + // If methods aren't available yet, retry after a short delay + setTimeout(() => { + if (graphRef.current && !isGraphReadyRef.current) { + initializeGraph(graphRef.current); + } + }, 100); + return; + } + + if (isGraphReadyRef.current) return; isGraphReadyRef.current = true; // Only set global actions if no instanceId is provided (for main graph) if (!instanceId) { setActions({ zoomIn: () => { - const zoom = graphInstance.zoom(); - graphInstance.zoom(zoom * 1.5); + if (graphRef.current && typeof graphRef.current.zoom === 'function') { + const zoom = graphRef.current.zoom(); + graphRef.current.zoom(zoom * 1.5); + } }, zoomOut: () => { - const zoom = graphInstance.zoom(); - graphInstance.zoom(zoom * 0.75); + if (graphRef.current && typeof graphRef.current.zoom === 'function') { + const zoom = graphRef.current.zoom(); + graphRef.current.zoom(zoom * 0.75); + } }, zoomToFit: () => { - graphInstance.zoomToFit(400); + if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { + graphRef.current.zoomToFit(400); + } } }); } @@ -180,7 +217,7 @@ const GraphViewer: React.FC = ({ onGraphRef?.(graphInstance); }, [setActions, onGraphRef, instanceId]); - // Handle graph ref changes + // Handle graph ref changes and ensure actions are set up useEffect(() => { if (graphRef.current) { initializeGraph(graphRef.current); @@ -199,6 +236,44 @@ const GraphViewer: React.FC = ({ }; }, [initializeGraph]); + // Additional effect to ensure actions are set up when graph is ready + useEffect(() => { + if (graphRef.current && !instanceId && !isGraphReadyRef.current) { + // Try to initialize again if the graph is available but not marked as ready + initializeGraph(graphRef.current); + } + }, [graphRef.current, initializeGraph, instanceId]); + + // Handle container size changes + useEffect(() => { + const updateSize = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setContainerSize({ + width: rect.width, + height: rect.height + }); + } + }; + + // Initial size + updateSize(); + + // Set up resize observer + const resizeObserver = new ResizeObserver(updateSize); + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + + // Also listen for window resize events + window.addEventListener('resize', updateSize); + + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updateSize); + }; + }, []); + // Memoized rendering check const shouldUseSimpleRendering = useMemo(() => nodes.length > CONSTANTS.NODE_COUNT_THRESHOLD || currentZoom < 1.5 @@ -218,12 +293,10 @@ const GraphViewer: React.FC = ({ val: CONSTANTS.NODE_DEFAULT_SIZE, neighbors: [] as any[], links: [] as any[] - }; + } as GraphNode & { neighbors: any[]; links: any[] }; }); - // Create a map for quick node lookup const nodeMap = new Map(transformedNodes.map(node => [node.id, node])); - // Group and transform edges const edgeGroups = new Map(); edges.forEach(edge => { @@ -233,7 +306,6 @@ const GraphViewer: React.FC = ({ } edgeGroups.get(key)!.push(edge); }); - const transformedEdges = edges.map((edge) => { const key = `${edge.source}-${edge.target}`; const group = edgeGroups.get(key)!; @@ -276,8 +348,6 @@ const GraphViewer: React.FC = ({ return { nodes: nds.map((nd) => ({ ...nd, - x: nd.position.x, - y: nd.position.y, // Preserve the neighbors and links from the original transformed node neighbors: nodeMap.get(nd.id)?.neighbors || [], links: nodeMap.get(nd.id)?.links || [] @@ -291,6 +361,20 @@ const GraphViewer: React.FC = ({ }; }, [nodes, edges, nodeColors, getSize, view]); + // Retry initialization if graph data changes and actions aren't set up + useEffect(() => { + if (graphData.nodes.length > 0 && graphRef.current && !instanceId && !isGraphReadyRef.current) { + // Small delay to ensure the graph has rendered + const timeoutId = setTimeout(() => { + if (graphRef.current && !isGraphReadyRef.current) { + initializeGraph(graphRef.current); + } + }, 200); + + return () => clearTimeout(timeoutId); + } + return undefined; + }, [graphData.nodes.length, initializeGraph, instanceId]); // New function to determine which labels should be visible based on zoom and weight @@ -350,32 +434,26 @@ const GraphViewer: React.FC = ({ return; } lastRenderTimeRef.current = now; - const newHighlightNodes = new Set(); const newHighlightLinks = new Set(); - if (node) { // Add the hovered node newHighlightNodes.add(node.id); - // Add connected nodes and links if (node.neighbors) { node.neighbors.forEach((neighbor: any) => { newHighlightNodes.add(neighbor.id); }); } - if (node.links) { node.links.forEach((link: any) => { newHighlightLinks.add(`${link.source.id}-${link.target.id}`); }); } - setHoverNode(node.id); } else { setHoverNode(null); } - setHighlightNodes(newHighlightNodes); setHighlightLinks(newHighlightLinks); }, []); @@ -408,14 +486,11 @@ const GraphViewer: React.FC = ({ try { // Use the graph's built-in method to convert graph coordinates to screen coordinates const screenCoords = graphRef.current.graph2ScreenCoords(node.x, node.y); - // Ensure tooltip stays within viewport bounds const tooltipWidth = 120; // Approximate tooltip width const tooltipHeight = 60; // Approximate tooltip height - let x = screenCoords.x; let y = screenCoords.y - 30; // Position above the node - // Adjust X position if tooltip would go off-screen if (x + tooltipWidth > window.innerWidth) { x = window.innerWidth - tooltipWidth - 10; @@ -423,12 +498,10 @@ const GraphViewer: React.FC = ({ if (x < 10) { x = 10; } - // Adjust Y position if tooltip would go off-screen if (y < tooltipHeight + 10) { y = screenCoords.y + 100; // Position below the node instead } - setTooltip({ x, y, @@ -485,9 +558,9 @@ const GraphViewer: React.FC = ({ const renderNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => { const size = Math.min((node.nodeSize + node.neighbors.length / 5), 20) * (settings.nodeSize.value / 100 + .4); node.val = size / 5 - const isHighlighted = highlightNodes.has(node.id); + const isHighlighted = highlightNodes.has(node.id) || isSelected(node.id); const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0; - const isHovered = hoverNode === node.id; + const isHovered = hoverNode === node.id || (isCurrent(node.id)); // Draw highlight ring for highlighted nodes if (isHighlighted) { @@ -534,45 +607,32 @@ const GraphViewer: React.FC = ({ if (!shouldShowLabel && !isHighlighted) { return; } - const fontSize = Math.max(CONSTANTS.MIN_FONT_SIZE, CONSTANTS.NODE_FONT_SIZE * (size / 7)); ctx.font = `${fontSize}px Sans-Serif`; - const bgHeight = fontSize + 2; const bgY = node.y + size / 2 + 1; const color = theme === "light" ? GRAPH_COLORS.TEXT_LIGHT : GRAPH_COLORS.TEXT_DARK; - ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - - // Adjust opacity based on whether it's a highlighted node or just visible in current layer if (isHighlighted) { ctx.fillStyle = color; } else { - // Fade out labels that are just visible due to layer, not highlighting ctx.fillStyle = `${color}4D`; // 30% opacity } - ctx.fillText(label, node.x, bgY + bgHeight / 2); } } - }, [shouldUseSimpleRendering, showLabels, showIcons, settings.nodeSize.value, theme, highlightNodes, highlightLinks, hoverNode, getVisibleLabels]); + }, [shouldUseSimpleRendering, showLabels, showIcons, isCurrent, isSelected, settings.nodeSize.value, theme, highlightNodes, highlightLinks, hoverNode, getVisibleLabels]); - // Optimized link rendering with reduced canvas state changes const renderLink = useCallback((link: any, ctx: CanvasRenderingContext2D) => { const { source: start, target: end } = link; - // Early exit for unbound links if (typeof start !== 'object' || typeof end !== 'object') return; - const linkKey = `${start.id}-${end.id}`; const isHighlighted = highlightLinks.has(linkKey); const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0; - - // Determine colors and styles once let strokeStyle: string; let lineWidth: number; let fillStyle: string; - if (isHighlighted) { strokeStyle = GRAPH_COLORS.LINK_HIGHLIGHTED; fillStyle = GRAPH_COLORS.LINK_HIGHLIGHTED; @@ -586,7 +646,6 @@ const GraphViewer: React.FC = ({ fillStyle = GRAPH_COLORS.LINK_DEFAULT; lineWidth = CONSTANTS.LINK_WIDTH * (settings.linkWidth.value / 5); } - // Draw connection line ctx.beginPath(); ctx.moveTo(start.x, start.y); @@ -594,125 +653,82 @@ const GraphViewer: React.FC = ({ ctx.strokeStyle = strokeStyle; ctx.lineWidth = lineWidth; ctx.stroke(); - // Draw directional arrow const arrowLength = settings.linkDirectionalArrowLength?.value; if (arrowLength && arrowLength > 0) { const arrowRelPos = settings.linkDirectionalArrowRelPos?.value || 1; - // Calculate arrow position along the link let arrowX = start.x + (end.x - start.x) * arrowRelPos; let arrowY = start.y + (end.y - start.y) * arrowRelPos; - // If arrow is at the target node (arrowRelPos = 1), offset it to be at the node's edge if (arrowRelPos === 1) { const dx = end.x - start.x; const dy = end.y - start.y; const distance = Math.sqrt(dx * dx + dy * dy); - if (distance > 0) { // Calculate target node size (same as in renderNode function) const targetNodeSize = (end.nodeSize || CONSTANTS.NODE_DEFAULT_SIZE) * (settings.nodeSize.value / 100 + 0.4); - // Calculate offset to place arrow at node edge const offset = targetNodeSize / distance; arrowX = end.x - dx * offset; arrowY = end.y - dy * offset; } } - // Calculate arrow direction const dx = end.x - start.x; const dy = end.y - start.y; const angle = Math.atan2(dy, dx); - // Draw arrow head ctx.save(); ctx.translate(arrowX, arrowY); ctx.rotate(angle); - ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-arrowLength, -arrowLength * 0.5); ctx.lineTo(-arrowLength, arrowLength * 0.5); ctx.closePath(); - ctx.fillStyle = fillStyle; ctx.fill(); ctx.restore(); } - // Early exit for simple rendering or no label if (shouldUseSimpleRendering || !link.label) return; - // Only show labels for highlighted links when there's any highlighting if (isHighlighted) { - - // Calculate label position and angle tempPos.x = (start.x + end.x) * 0.5; tempPos.y = (start.y + end.y) * 0.5; - const dx = end.x - start.x; const dy = end.y - start.y; let textAngle = Math.atan2(dy, dx); - // Flip text for readability if (textAngle > CONSTANTS.HALF_PI || textAngle < -CONSTANTS.HALF_PI) { textAngle += textAngle > 0 ? -CONSTANTS.PI : CONSTANTS.PI; } - // Measure and draw label ctx.font = LABEL_FONT_STRING; const textWidth = ctx.measureText(link.label).width; const padding = CONSTANTS.LABEL_FONT_SIZE * CONSTANTS.PADDING_RATIO; - tempDimensions[0] = textWidth + padding; tempDimensions[1] = CONSTANTS.LABEL_FONT_SIZE + padding; - const halfWidth = tempDimensions[0] * 0.5; const halfHeight = tempDimensions[1] * 0.5; - // Batch canvas operations ctx.save(); ctx.translate(tempPos.x, tempPos.y); ctx.rotate(textAngle); - // Background ctx.fillStyle = theme === "light" ? GRAPH_COLORS.BACKGROUND_LIGHT : GRAPH_COLORS.BACKGROUND_DARK; ctx.fillRect(-halfWidth, -halfHeight, tempDimensions[0], tempDimensions[1]); - // Text - follow same highlighting behavior as links ctx.fillStyle = isHighlighted ? GRAPH_COLORS.LINK_LABEL_HIGHLIGHTED : GRAPH_COLORS.LINK_LABEL_DEFAULT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(link.label, 0, 0); - ctx.restore(); } }, [shouldUseSimpleRendering, settings.linkWidth.value, settings.linkDirectionalArrowLength?.value, settings.linkDirectionalArrowRelPos?.value, settings.nodeSize.value, theme, highlightLinks, highlightNodes]); - // Container resize observer with debouncing - useEffect(() => { - if (!containerRef.current) return; - - let resizeTimeout: number; - const resizeObserver = new ResizeObserver(entries => { - // Debounce resize events - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - const { width: w, height: h } = entries[0].contentRect; - setDimensions({ width: w, height: h }); - }, 16) as any; // ~60fps - }); - - resizeObserver.observe(containerRef.current); - return () => { - resizeObserver.disconnect(); - clearTimeout(resizeTimeout); - }; - }, []); - // Restart simulation when settings change (debounced) useEffect(() => { let settingsTimeout: number | undefined; @@ -746,7 +762,11 @@ const GraphViewer: React.FC = ({ // Empty state if (!nodes.length) { return ( -
+
= ({ className={className} data-graph-container style={{ - width: width || '100%', - height: height || '100%', - minHeight: 300, - minWidth: 300, + width: '100%', + height: '100%', + minHeight: "100%", + minWidth: "100%", position: 'relative', ...style }} @@ -817,8 +837,8 @@ const GraphViewer: React.FC = ({ )} ''} nodeColor={node => shouldUseSimpleRendering ? node.nodeColor : GRAPH_COLORS.TRANSPARENT} nodeRelSize={6} @@ -842,10 +862,12 @@ const GraphViewer: React.FC = ({ onZoom={(zoom) => setCurrentZoom(zoom.k)} linkCanvasObject={renderLink} enableNodeDrag={!shouldUseSimpleRendering} - autoPauseRedraw={false} + autoPauseRedraw={true} onNodeHover={handleNodeHoverWithTooltip} onLinkHover={handleLinkHover} /> + {allowLasso && isLassoActive && }
); }; diff --git a/flowsint-app/src/renderer/src/components/graphs/index.tsx b/flowsint-app/src/renderer/src/components/graphs/index.tsx index 1ec3f06..3400852 100644 --- a/flowsint-app/src/renderer/src/components/graphs/index.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/index.tsx @@ -1,6 +1,6 @@ import { useLoaderData } from '@tanstack/react-router' -import { useEffect, useRef, memo, useState, lazy, Suspense } from 'react' -import { useGraphStore, type GraphNode, type GraphEdge } from '@/stores/graph-store' +import { useEffect, memo, useState, lazy, Suspense } from 'react' +import { useGraphStore } from '@/stores/graph-store' import { Toolbar } from './toolbar' import { cn } from '@/lib/utils' import { ArrowDownToLineIcon } from 'lucide-react' @@ -18,6 +18,7 @@ import NewActions from './add-item-dialog' import GraphSettings from './graph-settings' import GraphMain from './graph-main' import GlobalSettings, { KeyboardShortcuts } from './global-settings' +import { type GraphNode, type GraphEdge } from '@/types' const RelationshipsTable = lazy(() => import('@/components/table/relationships-view')) const Graph = lazy(() => import('./graph')) // const Wall = lazy(() => import('./wall/wall')) @@ -46,11 +47,12 @@ interface GraphPanelProps { } const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => { - const graphPanelRef = useRef(null) const handleOpenFormModal = useGraphStore(s => s.handleOpenFormModal) const nodes = useGraphStore(s => s.nodes) const view = useGraphControls((s) => s.view) const updateGraphData = useGraphStore(s => s.updateGraphData) + const setFilters = useGraphStore(s => s.setFilters) + const filters = useGraphStore(s => s.filters) const { actionItems, isLoading: isLoadingActionItems } = useActionItems() const { sketch } = useLoaderData({ from: '/_auth/dashboard/investigations/$investigationId/$type/$id', @@ -60,8 +62,17 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => { useEffect(() => { if (graphData?.nds && graphData?.rls) { updateGraphData(graphData.nds, graphData.rls) + const types = new Set(graphData.nds.map(n => n.data.type)) + setFilters( + { + ...filters, types: Array.from(types).map(t => ({ + type: t, + checked: true + })) + } + ) } - }, [graphData?.nds, graphData?.rls]) + }, [graphData?.nds, graphData?.rls, setFilters]) const handleDragOver = (e: React.DragEvent) => { e.preventDefault() @@ -115,7 +126,6 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => { return (
{ )} -
+
diff --git a/flowsint-app/src/renderer/src/components/graphs/lasso.tsx b/flowsint-app/src/renderer/src/components/graphs/lasso.tsx new file mode 100644 index 0000000..80527c2 --- /dev/null +++ b/flowsint-app/src/renderer/src/components/graphs/lasso.tsx @@ -0,0 +1,140 @@ +import { memo, useRef, type PointerEvent } from 'react'; +import { useGraphStore } from '@/stores/graph-store'; +import { GraphNode } from '@/types'; + +type NodePoints = ([number, number] | [number, number, number])[]; +type NodePointObject = Record; + +// Utilitaire pour générer un chemin SVG fermé à partir de points +function getSvgPathFromStroke(stroke: number[][]): string { + if (!stroke.length) return ""; + + const d = stroke.reduce( + (acc, [x0, y0], i, arr) => { + const [x1, y1] = arr[(i + 1) % arr.length]; + acc.push(x0, y0, ",", (x0 + x1) / 2, (y0 + y1) / 2); + return acc; + }, + ["M", ...stroke[0], "Q"], + ); + + d.push("Z"); + return d.join(" "); +} + +// Coordonnées relatives au canvas (évite le décalage) +function getRelativeCoordinates(e: PointerEvent, canvas: HTMLCanvasElement | null): [number, number] { + const rect = canvas?.getBoundingClientRect(); + if (!rect) return [0, 0]; + return [e.clientX - rect.left, e.clientY - rect.top]; +} + +export function Lasso({ partial, width, height, graph2ScreenCoords, nodes }: { partial: boolean; width: number; height: number, graph2ScreenCoords: (node: GraphNode) => { x: number, y: number }, nodes: GraphNode[] }) { + const setSelectedNodes = useGraphStore(s => s.setSelectedNodes); + const selectedNodes = useGraphStore(s => s.selectedNodes); + const setCurrentNode = useGraphStore(s => s.setCurrentNode); + const canvasRef = useRef(null); + const ctxRef = useRef(null); + const pointRef = useRef<[number, number][]>([]); + const nodePointsRef = useRef({}); + const lastSelectedIds = useRef>(new Set()); + + function handlePointerDown(e: PointerEvent) { + const canvas = canvasRef.current; + if (!canvas) return; + + canvas.setPointerCapture(e.pointerId); + pointRef.current = [getRelativeCoordinates(e, canvas)]; + + // Enregistre les coins de chaque node pour la détection + nodePointsRef.current = {}; + for (const node of nodes) { + const { x, y } = graph2ScreenCoords(node); + const w = 4, h = 4; + nodePointsRef.current[node.id] = [ + [x, y], + [x + w, y], + [x + w, y + h], + [x, y + h], + ]; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctxRef.current = ctx; + ctx.lineWidth = 1; + ctx.fillStyle = 'rgba(0, 89, 220, 0.08)'; + ctx.strokeStyle = 'rgba(0, 89, 220, 0.8)'; + } + + function handlePointerMove(e: PointerEvent) { + if (e.buttons !== 1) return; + + const canvas = canvasRef.current; + const ctx = ctxRef.current; + if (!canvas || !ctx) return; + + pointRef.current.push(getRelativeCoordinates(e, canvas)); + + const path = new Path2D(getSvgPathFromStroke(pointRef.current)); + ctx.clearRect(0, 0, width, height); + ctx.fill(path); + ctx.stroke(path); + + const localSelectedNodes: GraphNode[] = []; + + for (const [nodeId, points] of Object.entries(nodePointsRef.current)) { + const node = nodes.find(n => n.id === nodeId); + if (!node) continue; + + const isSelected = partial + ? points.some(([x, y]) => ctx.isPointInPath(path, x, y)) + : points.every(([x, y]) => ctx.isPointInPath(path, x, y)); + + if (isSelected) { + localSelectedNodes.push(node); + } + } + const newIds = new Set(localSelectedNodes.map(n => n.id)); + const oldIds = lastSelectedIds.current; + + let changed = newIds.size !== oldIds.size; + if (!changed) { + for (const id of newIds) { + if (!oldIds.has(id)) { + changed = true; + break; + } + } + } + + if (changed) { + lastSelectedIds.current = newIds; + setSelectedNodes(localSelectedNodes); + } + } + + function handlePointerUp(e: PointerEvent) { + canvasRef.current?.releasePointerCapture(e.pointerId); + pointRef.current = []; + if (selectedNodes.length === 1) + setCurrentNode(selectedNodes[0]) + ctxRef.current?.clearRect(0, 0, width, height); + } + + + return ( + + ); +} + +export default memo(Lasso); diff --git a/flowsint-app/src/renderer/src/components/graphs/launch-transform.tsx b/flowsint-app/src/renderer/src/components/graphs/launch-transform.tsx index 276abec..42a45b6 100644 --- a/flowsint-app/src/renderer/src/components/graphs/launch-transform.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/launch-transform.tsx @@ -20,10 +20,10 @@ import { formatDistanceToNow } from "date-fns" import { useQuery } from "@tanstack/react-query" import { transformService } from "@/api/transfrom-service" import { flowService } from '@/api/flow-service'; -import { useParams } from "@tanstack/react-router" +import { Link, useParams } from "@tanstack/react-router" import { capitalizeFirstLetter } from "@/lib/utils" import { Skeleton } from "@/components/ui/skeleton" -import { Search, FileCode2, Zap } from "lucide-react" +import { Search, FileCode2, Zap, PlusIcon, GitBranch, FileX, Sparkles } from "lucide-react" import { Transform, Flow } from "@/types" const LaunchTransformOrFlowPanel = memo(({ values, type, children }: { values: string[], type: string, children?: React.ReactNode }) => { @@ -190,10 +190,33 @@ const LaunchTransformOrFlowPanel = memo(({ values, type, children }: { values: s )) ) : ( -
-

- {transformsSearchQuery ? 'No transforms found' : 'No transforms available'} -

+
+
+
+ {transformsSearchQuery ? ( + + ) : ( + + )} +
+
+
+

+ {transformsSearchQuery ? 'No transforms found' : 'No transforms available'} +

+

+ {transformsSearchQuery + ? 'Try adjusting your search terms or browse all available transforms.' + : 'Transforms are automated data processing tools that can enrich your investigation data.' + } +

+
+ {!transformsSearchQuery && ( +
+ + Transforms will appear here when available +
+ )}
)} @@ -273,10 +296,41 @@ const LaunchTransformOrFlowPanel = memo(({ values, type, children }: { values: s )) ) : ( -
-

- {flowsSearchQuery ? 'No flows found' : 'No flows available'} -

+
+
+
+ {flowsSearchQuery ? ( + + ) : ( + + )} +
+
+
+

+ {flowsSearchQuery ? 'No flows found' : 'No flows available'} +

+

+ {flowsSearchQuery + ? 'Try adjusting your search terms or browse all available flows.' + : 'Flows are custom automation sequences that combine multiple transforms and data sources.' + } +

+
+ {!flowsSearchQuery && ( +
+
+ + Create your first flow to get started +
+ + + +
+ )}
)} diff --git a/flowsint-app/src/renderer/src/components/graphs/nodes-panel.tsx b/flowsint-app/src/renderer/src/components/graphs/nodes-panel.tsx index 96e7ef7..425638e 100644 --- a/flowsint-app/src/renderer/src/components/graphs/nodes-panel.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/nodes-panel.tsx @@ -7,8 +7,7 @@ import { Badge } from "@/components/ui/badge" import { TypeBadge } from "@/components/type-badge" import { Search, FunnelPlus, XIcon } from "lucide-react" import { Input } from "@/components/ui/input" -import { useActionItems } from "@/hooks/use-action-items" -import { cn, getAllNodeTypes } from "@/lib/utils" +import { cn } from "@/lib/utils" import { DropdownMenu, DropdownMenuContent, @@ -17,7 +16,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import type { GraphNode } from "@/stores/graph-store" +import { GraphNode } from '@/types'; import { Checkbox } from "@/components/ui/checkbox" const ITEM_HEIGHT = 40 @@ -28,11 +27,13 @@ const NodeRenderer = memo( setCurrentNode, onCheckboxChange, isNodeChecked, + isCurrent }: { node: any setCurrentNode: (node: GraphNode) => void onCheckboxChange: (node: GraphNode, checked: boolean) => void isNodeChecked: (nodeId: string) => boolean + isCurrent: (nodeId: string) => boolean }) => { const handleClick = useCallback(() => setCurrentNode(node), [node, setCurrentNode]) const handleCheckboxChange = useCallback( @@ -43,20 +44,19 @@ const NodeRenderer = memo( ) return ( -
+
- +
) }, @@ -67,17 +67,20 @@ const VirtualizedItem = memo(({ node, setCurrentNode, onCheckboxChange, - isNodeChecked + isNodeChecked, + isCurrent }: { index: number node: GraphNode setCurrentNode: (node: GraphNode) => void onCheckboxChange: (node: GraphNode, checked: boolean) => void isNodeChecked: (nodeId: string) => boolean + isCurrent: (nodeId: string) => boolean }) => { return ( { + const currentNode = useGraphStore((state) => state.currentNode) const setCurrentNode = useGraphStore((state) => state.setCurrentNode) const setSelectedNodes = useGraphStore((state) => state.setSelectedNodes) const selectedNodes = useGraphStore((state) => state.selectedNodes || []) const [searchQuery, setSearchQuery] = useState("") const [filters, setFilters] = useState(null) - const { actionItems } = useActionItems() + + const types = useMemo(() => Array.from(new Set(nodes.map(n => n.data.type))), [nodes]) // Ref pour le conteneur parent du virtualizer const parentRef = useRef(null) @@ -145,6 +150,13 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?: [selectedNodes], ) + const isCurrent = useCallback( + (nodeId: string) => { + return currentNode?.id === nodeId + }, + [currentNode], + ) + const handleCheckAll = useCallback( (checked: boolean) => { if (!checked) { @@ -226,7 +238,7 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?: toggleFilter(null)}> All - {getAllNodeTypes(actionItems || []).map((type) => ( + {types.map((type: string) => ( { export default memo(SelectedItemsPanel) -const SelectedNodeItem = memo(({ node, color }: { node: GraphNode, color: string }) => { +const SelectedNodeItem = memo(({ node }: { node: GraphNode, color: string }) => { return ( - - + {node.data?.label || 'Unknown'} @@ -156,7 +156,7 @@ const ActionBar = () => { size={"sm"} className="rounded-full h-7" > - Launch ({selectedNodes.length}) + ({selectedNodes.length})
diff --git a/flowsint-app/src/renderer/src/components/graphs/toolbar.tsx b/flowsint-app/src/renderer/src/components/graphs/toolbar.tsx index f94c6a1..8733d1c 100644 --- a/flowsint-app/src/renderer/src/components/graphs/toolbar.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/toolbar.tsx @@ -13,11 +13,13 @@ import { SlidersHorizontal, GitFork, ArrowRightLeft, - FunnelPlus + FunnelPlus, + GitPullRequestArrow, + LassoSelect } from "lucide-react" import { memo, useCallback } from "react" import { toast } from "sonner" -import { cn, isMac } from "@/lib/utils" +import { cn } from "@/lib/utils" import ForceControls from './force-controls' import Filters from "./filters" import { useGraphStore } from "@/stores/graph-store" @@ -28,13 +30,15 @@ export const ToolbarButton = memo(function ToolbarButton({ tooltip, onClick, disabled = false, - badge = null + badge = null, + toggled = false }: { icon: React.ReactNode; tooltip: string | React.ReactNode; onClick?: () => void; disabled?: boolean; badge?: number | null; + toggled?: boolean | null }) { return ( @@ -45,7 +49,7 @@ export const ToolbarButton = memo(function ToolbarButton({ disabled={disabled} variant="outline" size="icon" - className="h-8 w-8 relative shadow-none" + className={cn("h-8 w-8 relative shadow-none", toggled && "bg-muted hover:bg-muted")} > {icon} {badge && {badge}} @@ -64,7 +68,10 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean const zoomOut = useGraphControls((s) => s.zoomOut); const onLayout = useGraphControls((s) => s.onLayout); const refetchGraph = useGraphControls((s) => s.refetchGraph) - const filters = useGraphStore(s => s.filters) + const isLassoActive = useGraphControls((s) => s.isLassoActive) + const setIsLassoActive = useGraphControls((s) => s.setIsLassoActive) + const selectedNodes = useGraphStore(s => s.selectedNodes) + const setOpenAddRelationDialog = useGraphStore((state) => state.setOpenAddRelationDialog) const handleRefresh = useCallback(() => { try { @@ -96,81 +103,114 @@ export const Toolbar = memo(function Toolbar({ isLoading }: { isLoading: boolean onLayout && onLayout("dagre-tb") }, [onLayout, setView]) + const handleOpenAddRelationDialog = useCallback(() => { + setOpenAddRelationDialog(true) + }, [setOpenAddRelationDialog]) + + const handleLassoSelect = useCallback(() => { + setIsLassoActive(!isLassoActive) + }, [setIsLassoActive, isLassoActive]) + + const areExactlyTwoSelected = selectedNodes.length === 2 + return ( -
- - } - tooltip="Zoom In" - onClick={zoomIn} - disabled={["table", "relationships"].includes(view)} - /> - } - tooltip="Zoom Out" - onClick={zoomOut} - disabled={["table", "relationships"].includes(view)} - /> - } - tooltip="Fit to View" - onClick={zoomToFit} - disabled={["table", "relationships"].includes(view)} - /> - - } - tooltip={`Hierarchy`} - disabled={["hierarchy"].includes(view)} - onClick={handleDagreLayoutTB} - /> - - } - disabled={["force"].includes(view)} - tooltip={"Graph view"} - onClick={handleForceLayout} - /> - } - tooltip={"Table view"} - disabled={["table"].includes(view)} - onClick={handleTableLayout} - /> - } - tooltip={"Relationships view"} - disabled={["relationships"].includes(view)} - onClick={handleRelationshipsLayout} - /> - } - tooltip={"Map view"} - disabled={["map"].includes(view)} - onClick={handleMapLayout} - /> - } - tooltip="Refresh Graph Data" - /> - +
+
+ } - tooltip="Settings" + icon={} + tooltip="Connect" + onClick={handleOpenAddRelationDialog} + disabled={!areExactlyTwoSelected} + badge={areExactlyTwoSelected ? 2 : null} + /> - - } + tooltip="Zoom In" + onClick={zoomIn} + disabled={!["force", "hierarchy"].includes(view) || isLassoActive} + /> + } + tooltip="Zoom Out" + onClick={zoomOut} + disabled={!["force", "hierarchy"].includes(view) || isLassoActive} + /> + } + tooltip="Fit to View" + onClick={zoomToFit} + disabled={!["force", "hierarchy"].includes(view) || isLassoActive} + /> + } + tooltip={"Lasso select"} + onClick={handleLassoSelect} + toggled={isLassoActive} + disabled={!["force", "hierarchy"].includes(view)} + /> + + } + tooltip="Settings" + /> + + + } + tooltip="Filters" + /> + + } - tooltip="Filters" - badge={filters && filters?.length > 0 ? filters?.length : null} + icon={} + tooltip="Refresh" /> - - + +
+
+ + } + tooltip={`Hierarchy`} + toggled={["hierarchy"].includes(view)} + disabled={["hierarchy"].includes(view)} + onClick={handleDagreLayoutTB} + /> + } + disabled={["force"].includes(view)} + tooltip={"Graph view"} + toggled={["force"].includes(view)} + onClick={handleForceLayout} + /> + } + tooltip={"Table view"} + disabled={["table"].includes(view)} + toggled={["table"].includes(view)} + onClick={handleTableLayout} + /> + } + tooltip={"Relationships view"} + disabled={["relationships"].includes(view)} + toggled={["relationships"].includes(view)} + onClick={handleRelationshipsLayout} + /> + } + tooltip={"Map view"} + disabled={["map"].includes(view)} + toggled={["map"].includes(view)} + onClick={handleMapLayout} + /> + +
) }) \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/components/table/nodes-view.tsx b/flowsint-app/src/renderer/src/components/table/nodes-view.tsx index a7527b0..33a8264 100644 --- a/flowsint-app/src/renderer/src/components/table/nodes-view.tsx +++ b/flowsint-app/src/renderer/src/components/table/nodes-view.tsx @@ -1,4 +1,4 @@ -import { GraphNode, useGraphStore } from "@/stores/graph-store"; +import { useGraphStore } from "@/stores/graph-store"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef, useState, useMemo, useCallback } from "react"; import { Input } from "@/components/ui/input"; @@ -14,6 +14,8 @@ import { SelectValue, } from "@/components/ui/select"; import { CopyButton } from "../copy"; +import { GraphNode } from '@/types'; + export type RelationshipType = { source: GraphNode diff --git a/flowsint-app/src/renderer/src/components/table/relationships-view.tsx b/flowsint-app/src/renderer/src/components/table/relationships-view.tsx index 1799374..450f654 100644 --- a/flowsint-app/src/renderer/src/components/table/relationships-view.tsx +++ b/flowsint-app/src/renderer/src/components/table/relationships-view.tsx @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useParams } from "@tanstack/react-router"; import { sketchService } from "@/api/sketch-service"; -import { GraphNode, useGraphStore } from "@/stores/graph-store"; +import { useGraphStore } from "@/stores/graph-store"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useRef, useState, useMemo, useCallback } from "react"; import { Input } from "@/components/ui/input"; @@ -19,6 +19,8 @@ import { } from "@/components/ui/select"; import { CopyButton } from "../copy"; import { RelationshipType } from "@/types"; +import { GraphNode } from '@/types'; + const ITEM_HEIGHT = 67; // Balanced spacing between items (55px card + 12px padding) diff --git a/flowsint-app/src/renderer/src/components/xyflow/context-menu.tsx b/flowsint-app/src/renderer/src/components/xyflow/context-menu.tsx index cfc15d2..9864ea8 100644 --- a/flowsint-app/src/renderer/src/components/xyflow/context-menu.tsx +++ b/flowsint-app/src/renderer/src/components/xyflow/context-menu.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import type { Node } from '@xyflow/react'; -interface ContextMenuProps { - node: T; +interface ContextMenuProps { top?: number; left?: number; right?: number; @@ -15,8 +13,8 @@ interface ContextMenuProps { rawLeft?: number; } -export default function ContextMenu({ - node, +export default function ContextMenu({ + top, left, right, @@ -27,7 +25,7 @@ export default function ContextMenu({ rawLeft, children, ...props -}: ContextMenuProps) { +}: ContextMenuProps) { // If raw position is provided, calculate overflow and adjust position let finalTop = top; let finalLeft = left; diff --git a/flowsint-app/src/renderer/src/lib/utils.ts b/flowsint-app/src/renderer/src/lib/utils.ts index 89b9a68..8b2efbd 100644 --- a/flowsint-app/src/renderer/src/lib/utils.ts +++ b/flowsint-app/src/renderer/src/lib/utils.ts @@ -4,6 +4,7 @@ import Dagre from '@dagrejs/dagre'; import { type Edge, Position, type Node } from '@xyflow/react'; import * as d3 from "d3-force" +import { GraphEdge, GraphNode } from '@/types'; interface NodePosition { x: number; @@ -196,8 +197,8 @@ export const getForceLayoutedElements = ( } } -export const getDagreLayoutedElements = (nodes: Node[], - edges: Edge[], +export const getDagreLayoutedElements = (nodes: GraphNode[], + edges: GraphEdge[], options: LayoutOptions = { direction: "TB", strength: -300, @@ -207,29 +208,21 @@ export const getDagreLayoutedElements = (nodes: Node[], const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); g.setGraph({ rankdir: options.direction }); - edges.forEach((edge) => g.setEdge(edge.source, edge.target)); nodes.forEach((node) => g.setNode(node.id, { ...node, - targetPosition: options.direction === "LR" ? 'left' : 'top', - sourcePosition: options.direction === "LR" ? 'right' : 'bottom', - width: node.measured?.width ?? 0, - height: node.measured?.height ?? 0, + width: node.nodeSize ?? 0, + height: node.nodeSize ?? 0, }), ); - Dagre.layout(g); - return { nodes: nodes.map((node) => { const position = g.node(node.id); - // We are shifting the dagre node position (anchor=center center) to the top left - // so it matches the React Flow node anchor point (top left). - const x = position.x - (node.measured?.width ?? 0) / 2; - const y = position.y - (node.measured?.height ?? 0) / 2; - - return { ...node, position: { x, y } }; + const x = position.x - (node.nodeSize ?? 0) / 2; + const y = position.y - (node.nodeSize ?? 0) / 2; + return { ...node, x, y }; }), edges, }; diff --git a/flowsint-app/src/renderer/src/stores/graph-controls-store.ts b/flowsint-app/src/renderer/src/stores/graph-controls-store.ts index c245394..3ff60f1 100644 --- a/flowsint-app/src/renderer/src/stores/graph-controls-store.ts +++ b/flowsint-app/src/renderer/src/stores/graph-controls-store.ts @@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware'; type GraphControlsStore = { view: 'force' | 'hierarchy' | 'table' | 'map' | 'relationships'; + isLassoActive: boolean; zoomToFit: () => void; zoomIn: () => void; zoomOut: () => void; @@ -10,12 +11,14 @@ type GraphControlsStore = { setActions: (actions: Partial) => void; refetchGraph: () => void; setView: (view: 'force' | 'hierarchy' | 'table' | 'map' | 'relationships') => void; + setIsLassoActive: (active: boolean) => void }; export const useGraphControls = create()( persist( (set) => ({ view: 'hierarchy', + isLassoActive: false, zoomToFit: () => { }, zoomIn: () => { }, zoomOut: () => { }, @@ -23,6 +26,7 @@ export const useGraphControls = create()( setActions: (actions) => set(actions), refetchGraph: () => { }, setView: (view) => set({ view }), + setIsLassoActive: (active) => set({ isLassoActive: active }) }), { name: 'graph-controls-storage', diff --git a/flowsint-app/src/renderer/src/stores/graph-store.ts b/flowsint-app/src/renderer/src/stores/graph-store.ts index 4c8b01a..a24d1a8 100644 --- a/flowsint-app/src/renderer/src/stores/graph-store.ts +++ b/flowsint-app/src/renderer/src/stores/graph-store.ts @@ -1,25 +1,15 @@ import { create } from "zustand" import { persist } from "zustand/middleware" -import type { EdgeData, NodeData } from "@/types" -import { - type Node, - type Edge, - type OnNodesChange, - type OnEdgesChange, - type OnConnect, - type Connection, - applyNodeChanges, - applyEdgeChanges -} from "@xyflow/react" +import type { GraphNode, GraphEdge, NodeData } from "@/types" import { type ActionItem } from "@/lib/action-items" -export type GraphNode = Node & { - collapsed?: boolean; - hidden?: boolean; - x?: number; - y?: number; +export type TypeFilter = { + type: string + checked: boolean +} +export type Filters = { + types: TypeFilter[] } -export type GraphEdge = Edge & { caption?: string } interface GraphState { // === Graph === @@ -35,17 +25,12 @@ interface GraphState { removeEdges: (edgeIds: string[]) => void updateGraphData: (nodes: GraphNode[], edges: GraphEdge[]) => void updateNode: (nodeId: string, updates: Partial) => void - updateEdge: (edgeId: string, updates: Partial) => void - onNodesChange: OnNodesChange - onEdgesChange: OnEdgesChange - onConnect: OnConnect + updateEdge: (edgeId: string, updates: Partial) => void reset: () => void // === Selection & Current === currentNode: GraphNode | null selectedNodes: GraphNode[] - isCurrent: (nodeId: string) => boolean - isSelected: (nodeId: string) => boolean setCurrentNode: (node: GraphNode | null) => void setSelectedNodes: (nodes: GraphNode[]) => void clearSelectedNodes: () => void @@ -74,8 +59,9 @@ interface GraphState { handleEdit: (node: GraphNode) => void // === Filters === - filters: string[] | null - setFilters: (filters: string[] | null) => void + filters: Filters + setFilters: (filters: Filters) => void + toggleTypeFilter: (filter: TypeFilter) => void // === Collapse/Expand logic === toggleCollapse: (nodeId: string) => void @@ -88,14 +74,13 @@ interface GraphState { } // --- Helpers --- -const computeFilteredNodes = (nodes: GraphNode[], filters: string[] | null): GraphNode[] => { - if (!filters || Object.keys(filters).length === 0) return nodes - return nodes.filter((node) => { - if (filters && Array.isArray(filters)) { - return filters.includes(node.data.type) - } - return true - }) +const computeFilteredNodes = (nodes: GraphNode[], filters: Filters): GraphNode[] => { + // types + const areAllToggled = filters.types.every(t => t.checked) + const areNoneToggled = filters.types.every(t => !t.checked) + if (areNoneToggled || areAllToggled) return nodes + const types = filters.types.filter(t => !t.checked).map(t => t.type) + return nodes.filter((node) => !types.includes(node.data.type)) } const computeFilteredEdges = (edges: GraphEdge[], filteredNodes: GraphNode[]): GraphEdge[] => { @@ -192,68 +177,16 @@ export const useGraphStore = create()( updateEdge: (edgeId, updates) => { const { edges, nodes, filters } = get() const updatedEdges = edges.map(edge => - edge.id === edgeId ? { ...edge, data: { ...edge.data, ...updates } as EdgeData } : edge + edge.id === edgeId ? { ...edge, data: { ...edge, ...updates } as GraphEdge } : edge ) const filteredNodes = computeFilteredNodes(nodes, filters) const filteredEdges = computeFilteredEdges(updatedEdges, filteredNodes) set({ edges: updatedEdges, filteredNodes, filteredEdges }) }, - onNodesChange: (changes) => { - const { nodes, edges, filters } = get() - const updatedNodes = applyNodeChanges(changes, nodes) as GraphNode[] - const filteredNodes = computeFilteredNodes(updatedNodes, filters) - const filteredEdges = computeFilteredEdges(edges, filteredNodes) - set({ nodes: updatedNodes, filteredNodes, filteredEdges }) - }, - - onEdgesChange: (changes) => { - const { edges, nodes, filters } = get() - const updatedEdges = applyEdgeChanges(changes, edges) as GraphEdge[] - const filteredNodes = computeFilteredNodes(nodes, filters) - const filteredEdges = computeFilteredEdges(updatedEdges, filteredNodes) - set({ edges: updatedEdges, filteredNodes, filteredEdges }) - }, - - onConnect: (connection: Connection) => { - const { edges, nodes, filters } = get() - const edge: GraphEdge = { - id: `${connection.source}-${connection.target}`, - source: connection.source!, - target: connection.target!, - sourceHandle: connection.sourceHandle, - targetHandle: connection.targetHandle, - } - const newEdges = [...edges, edge] - const filteredNodes = computeFilteredNodes(nodes, filters) - const filteredEdges = computeFilteredEdges(newEdges, filteredNodes) - set({ edges: newEdges, filteredNodes, filteredEdges }) - }, - - reset: () => { - set({ - currentNode: null, - selectedNodes: [], - relatedNodeToAdd: null, - openMainDialog: false, - openFormDialog: false, - openAddRelationDialog: false, - openNodeEditorModal: false, - currentNodeType: null, - // filters: {}, - filteredNodes: get().nodes, - filteredEdges: get().edges, - }) - }, - // === Selection & Current === currentNode: null, selectedNodes: [], - isCurrent: (nodeId) => get().currentNode?.id === nodeId, - isSelected: (nodeId) => { - const { selectedNodes, isCurrent } = get() - return selectedNodes.some((node) => node.id === nodeId) || isCurrent(nodeId) - }, setCurrentNode: (node) => { const { currentNode } = get() // Only update if the node is actually different @@ -282,7 +215,7 @@ export const useGraphStore = create()( } // Only update if there are actual changes - const hasSelectionChanges = newSelected.length !== selectedNodes.length || + const hasSelectionChanges = newSelected.length !== selectedNodes.length || newSelected.some((n, i) => n.id !== selectedNodes[i]?.id) const hasCurrentNodeChanges = newCurrentNode?.id !== currentNode?.id @@ -333,7 +266,18 @@ export const useGraphStore = create()( }, // === Filters === - filters: [], + filters: { + types: [{ + type: "domain", + checked: true + }, { + type: "ip", + checked: true + }, { + type: "individual", + checked: true + }] + }, setFilters: (filters) => { const { nodes, edges } = get() const filteredNodes = computeFilteredNodes(nodes, filters) @@ -341,6 +285,20 @@ export const useGraphStore = create()( set({ filters, filteredNodes, filteredEdges }) }, + toggleTypeFilter: (filter) => { + const { filters, nodes, edges } = get() + const newTypes = filters.types.map((f: TypeFilter) => { + if (f.type === filter.type) return { + type: f.type, checked: !f.checked + } + return f + }) + const newFilters = { ...filters, types: newTypes } + const filteredNodes = computeFilteredNodes(nodes, newFilters) + const filteredEdges = computeFilteredEdges(edges, filteredNodes) + set({ filters: newFilters, filteredNodes, filteredEdges }) + }, + // === Collapse/Expand logic === toggleCollapse: (nodeId) => { const { nodes, edges, filters } = get() @@ -380,23 +338,28 @@ export const useGraphStore = create()( set({ nodes: newNodes, edges: newEdges, filteredNodes, filteredEdges }) }, + reset: () => { + set({ + currentNode: null, + selectedNodes: [], + relatedNodeToAdd: null, + openMainDialog: false, + openFormDialog: false, + openAddRelationDialog: false, + openNodeEditorModal: false, + currentNodeType: null, + filteredNodes: get().nodes, + filteredEdges: get().edges, + }) + }, + // === Utils === nodesLength: 0, edgesLength: 0, }), { name: "graph-store", - partialize: (state) => ({ filters: state.filters }), - onRehydrateStorage: () => (state) => { - if (state) { - // recalcul des nodes/edges filtrés après rehydratation - const { nodes, edges, filters } = state - const filteredNodes = computeFilteredNodes(nodes, filters) - const filteredEdges = computeFilteredEdges(edges, filteredNodes) - state.filteredNodes = filteredNodes - state.filteredEdges = filteredEdges - } - }, + partialize: (state) => ({ edgesLength: state.edgesLength }), } ) ) diff --git a/flowsint-app/src/renderer/src/stores/wall-store.ts b/flowsint-app/src/renderer/src/stores/wall-store.ts deleted file mode 100644 index 449fc18..0000000 --- a/flowsint-app/src/renderer/src/stores/wall-store.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { create } from "zustand" -import { Node, Edge, Connection } from "@xyflow/react" - -interface WallState { - nodes: Node[] - edges: Edge[] - currentNode: Node | null - selectedNodes: Node[] - onNodesChange: (changes: any) => void - onEdgesChange: (changes: any) => void - onConnect: (connection: Connection) => void - setCurrentNode: (node: Node | null) => void - setSelectedNodes: (nodes: Node[]) => void - saveWall: (nodes: Node[], edges: Edge[]) => Promise - deleteWall: () => Promise -} - -export const useWallStore = create((set) => ({ - nodes: [], - edges: [], - currentNode: null, - selectedNodes: [], - onNodesChange: (changes) => - set((state) => ({ - nodes: changes.reduce((acc: Node[], change: any) => { - if (change.type === "remove") { - return acc.filter((node) => node.id !== change.id) - } - if (change.type === "add") { - return [...acc, change.item] - } - if (change.type === "position" || change.type === "dimensions") { - return acc.map((node) => - node.id === change.id ? { ...node, ...change } : node - ) - } - return acc - }, state.nodes), - })), - onEdgesChange: (changes) => - set((state) => ({ - edges: changes.reduce((acc: Edge[], change: any) => { - if (change.type === "remove") { - return acc.filter((edge) => edge.id !== change.id) - } - if (change.type === "add") { - return [...acc, change.item] - } - return acc - }, state.edges), - })), - onConnect: (connection) => - set((state) => ({ - edges: [ - ...state.edges, - { - id: `e${state.edges.length + 1}`, - source: connection.source!, - target: connection.target!, - }, - ], - })), - setCurrentNode: (node) => set({ currentNode: node }), - setSelectedNodes: (nodes) => set({ selectedNodes: nodes }), - saveWall: async (nodes, edges) => { - // TODO: Implement save functionality - console.log('Saving wall:', { nodes, edges }) - }, - deleteWall: async () => { - // TODO: Implement delete functionality - console.log('Deleting wall') - } -})) \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/styles.css b/flowsint-app/src/renderer/src/styles.css index 6c73f52..2680532 100644 --- a/flowsint-app/src/renderer/src/styles.css +++ b/flowsint-app/src/renderer/src/styles.css @@ -1024,7 +1024,7 @@ pre { --muted-foreground: oklch(0.6268 0 0); --accent: oklch(0.3211 0 0); --accent-foreground: oklch(0.8109 0 0); - --destructive: oklch(0.5940 0.0443 196.0233); + --destructive: oklch(0.6368 0.2078 25.3313); --destructive-foreground: oklch(0.1797 0.0043 308.1928); --border: oklch(0.2520 0 0); --input: oklch(0.2520 0 0); @@ -1118,4 +1118,16 @@ pre { body { letter-spacing: var(--tracking-normal); +} + +.tool-overlay { + pointer-events: auto; + position: absolute; + top: 0; + left: 0; + z-index: 4; + height: 100%; + width: 100%; + transform-origin: top left; + touch-action: none; } \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/types/graph.ts b/flowsint-app/src/renderer/src/types/graph.ts index ddedf85..b36a8a5 100644 --- a/flowsint-app/src/renderer/src/types/graph.ts +++ b/flowsint-app/src/renderer/src/types/graph.ts @@ -1,31 +1,40 @@ -import type { Edge, Node } from "@xyflow/react"; export type NodeData = { id: string; type: string, - caption: string, label: string, created_at: string, // Allow any other properties [key: string]: any; }; -export type EdgeData = { - from: string; - to: string; - date: string; +export type GraphNode = { + collapsed?: boolean; + hidden?: boolean; + x?: number; + y?: number; + nodeLabel?: string; + nodeColor?: string; + nodeSize?: number; + nodeType?: string; + val?: number; + neighbors?: any[]; + links?: any[]; + id: string; + data: NodeData +} + +export type GraphEdge = { + source: string; + target: string; + date?: string; id: string; label: string; caption?: string; - type: string; + type?: string; confidence_level?: number | string }; -export type InvestigationGraph = { - nodes: Node[]; - edges: Edge[]; -}; - export type ForceGraphSetting = { value: any, min?: number, diff --git a/flowsint-app/yarn.lock b/flowsint-app/yarn.lock index f791d1c..f6053f3 100644 --- a/flowsint-app/yarn.lock +++ b/flowsint-app/yarn.lock @@ -7374,6 +7374,11 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== +perfect-freehand@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.2.2.tgz#292f65b72df0c7f57a89c4b346b50d7139014172" + integrity sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ== + picocolors@^1.0.1, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" diff --git a/flowsint-transforms/src/flowsint_transforms/domain/to_history.py b/flowsint-transforms/src/flowsint_transforms/domain/to_history.py index 0d88f65..49bacfc 100644 --- a/flowsint-transforms/src/flowsint_transforms/domain/to_history.py +++ b/flowsint-transforms/src/flowsint_transforms/domain/to_history.py @@ -7,7 +7,7 @@ from flowsint_types.domain import Domain from flowsint_types.individual import Individual from flowsint_types.organization import Organization from flowsint_core.utils import is_valid_domain, is_root_domain -from flowsint_types.address import PhysicalAddress +from flowsint_types.address import Location from flowsint_core.core.logger import Logger from tools.network.whoxy import WhoxyTool from dotenv import load_dotenv @@ -299,7 +299,7 @@ class DomainToHistoryScanner(Scanner): pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(pattern, email)) - def __extract_physical_address(self, contact: Dict[str, Any]) -> PhysicalAddress: + def __extract_physical_address(self, contact: Dict[str, Any]) -> Location: """Extract physical address from contact data.""" address = contact.get("mailing_address", "") city = contact.get("city_name", "") @@ -315,7 +315,7 @@ class DomainToHistoryScanner(Scanner): if not all([address, city, zip_code, country]): return None - return PhysicalAddress( + return Location( address=address, city=city, zip=zip_code, country=country ) diff --git a/flowsint-transforms/src/flowsint_transforms/domain/to_whois.py b/flowsint-transforms/src/flowsint_transforms/domain/to_whois.py index 7f08f68..8671a10 100644 --- a/flowsint-transforms/src/flowsint_transforms/domain/to_whois.py +++ b/flowsint-transforms/src/flowsint_transforms/domain/to_whois.py @@ -6,6 +6,7 @@ from flowsint_types.domain import Domain, Domain from flowsint_types.whois import Whois from flowsint_types.email import Email from flowsint_core.core.logger import Logger +from datetime import datetime class WhoisScanner(Scanner): @@ -117,6 +118,14 @@ class WhoisScanner(Scanner): # Create whois node whois_key = f"{whois_obj.domain}_{self.sketch_id}" + whois_label = f"Whois-{whois_obj.domain}" + # Creating unique label + date_format = "%Y-%m-%dT%H:%M:%S" + try: + year = datetime.strptime(whois_obj.creation_date, date_format).year + whois_label = f"{whois_label}-{year}" + except Exception: + continue self.create_node( "whois", "whois_id", @@ -129,7 +138,7 @@ class WhoisScanner(Scanner): creation_date=whois_obj.creation_date, expiration_date=whois_obj.expiration_date, email=whois_obj.email.email if whois_obj.email else None, - label="Whois", + label=whois_label, type="whois", ) @@ -173,7 +182,9 @@ class WhoisScanner(Scanner): ) if whois_obj.email: - self.create_node("email", "email", whois_obj.email.email, **whois_obj.email.__dict__) + self.create_node( + "email", "email", whois_obj.email.email, **whois_obj.email.__dict__ + ) self.create_relationship( "whois", "whois_id", diff --git a/flowsint-transforms/src/flowsint_transforms/email/to_domains.py b/flowsint-transforms/src/flowsint_transforms/email/to_domains.py index a1ee8f1..ff2d288 100644 --- a/flowsint-transforms/src/flowsint_transforms/email/to_domains.py +++ b/flowsint-transforms/src/flowsint_transforms/email/to_domains.py @@ -5,7 +5,7 @@ from flowsint_core.core.scanner_base import Scanner from flowsint_types.domain import Domain from flowsint_types.individual import Individual from flowsint_types.email import Email -from flowsint_types.address import PhysicalAddress +from flowsint_types.address import Location from flowsint_core.core.logger import Logger from tools.network.whoxy import WhoxyTool from dotenv import load_dotenv @@ -175,7 +175,7 @@ class EmailToDomainsScanner(Scanner): pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(pattern, email)) - def __extract_physical_address(self, contact: Dict[str, Any]) -> PhysicalAddress: + def __extract_physical_address(self, contact: Dict[str, Any]) -> Location: """Extract physical address from contact data.""" address = contact.get("mailing_address", "") city = contact.get("city_name", "") @@ -189,7 +189,7 @@ class EmailToDomainsScanner(Scanner): if not all([address, city, zip_code, country]): return None - return PhysicalAddress( + return Location( address=address, city=city, zip=zip_code, country=country ) diff --git a/flowsint-transforms/src/flowsint_transforms/individual/to_domains.py b/flowsint-transforms/src/flowsint_transforms/individual/to_domains.py index 062396f..5f2a2d2 100644 --- a/flowsint-transforms/src/flowsint_transforms/individual/to_domains.py +++ b/flowsint-transforms/src/flowsint_transforms/individual/to_domains.py @@ -5,7 +5,7 @@ from flowsint_core.core.scanner_base import Scanner from flowsint_types.domain import Domain from flowsint_types.organization import Organization from flowsint_types.individual import Individual -from flowsint_types.address import PhysicalAddress +from flowsint_types.address import Location from flowsint_core.core.logger import Logger from tools.network.whoxy import WhoxyTool from dotenv import load_dotenv @@ -194,7 +194,7 @@ class IndividualToDomainsScanner(Scanner): pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(pattern, email)) - def __extract_physical_address(self, contact: Dict[str, Any]) -> PhysicalAddress: + def __extract_physical_address(self, contact: Dict[str, Any]) -> Location: """Extract physical address from contact data.""" address = contact.get("mailing_address", "") city = contact.get("city_name", "") @@ -208,7 +208,7 @@ class IndividualToDomainsScanner(Scanner): if not all([address, city, zip_code, country]): return None - return PhysicalAddress( + return Location( address=address, city=city, zip=zip_code, country=country ) diff --git a/flowsint-transforms/src/flowsint_transforms/individual/to_org.py b/flowsint-transforms/src/flowsint_transforms/individual/to_org.py index 282370b..3ab74da 100644 --- a/flowsint-transforms/src/flowsint_transforms/individual/to_org.py +++ b/flowsint-transforms/src/flowsint_transforms/individual/to_org.py @@ -81,12 +81,12 @@ class IndividualToOrgScanner(Scanner): try: # Extract siege data siege = company.get("siege", {}) - # Create PhysicalAddress for siege_geo_adresse if coordinates exist + # Create Location for siege_geo_adresse if coordinates exist siege_geo_adresse = None if siege.get("latitude") and siege.get("longitude"): - from flowsint_types.address import PhysicalAddress + from flowsint_types.address import Location - siege_geo_adresse = PhysicalAddress( + siege_geo_adresse = Location( address=siege.get("adresse", ""), city=siege.get("libelle_commune", ""), country="FR", # SIRENE is French registry @@ -149,7 +149,6 @@ class IndividualToOrgScanner(Scanner): date_mise_a_jour_rne=company.get("date_mise_a_jour_rne"), # Legal information nature_juridique=company.get("nature_juridique"), - etat_administratif=company.get("etat_administratif"), statut_diffusion=company.get("statut_diffusion"), # Siege (Headquarters) information siege_activite_principale=siege.get("activite_principale"), diff --git a/flowsint-transforms/src/flowsint_transforms/organization/to_domains.py b/flowsint-transforms/src/flowsint_transforms/organization/to_domains.py index 2ef16c2..609ee77 100644 --- a/flowsint-transforms/src/flowsint_transforms/organization/to_domains.py +++ b/flowsint-transforms/src/flowsint_transforms/organization/to_domains.py @@ -7,7 +7,7 @@ from flowsint_types.organization import Organization from flowsint_types.individual import Individual from flowsint_types.email import Email from flowsint_types.phone import Phone -from flowsint_types.address import PhysicalAddress +from flowsint_types.address import Location from flowsint_core.core.logger import Logger from tools.network.whoxy import WhoxyTool from dotenv import load_dotenv @@ -176,7 +176,7 @@ class OrgToDomainsScanner(Scanner): pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" return bool(re.match(pattern, email)) - def __extract_physical_address(self, contact: Dict[str, Any]) -> PhysicalAddress: + def __extract_physical_address(self, contact: Dict[str, Any]) -> Location: """Extract physical address from contact data.""" address = contact.get("mailing_address", "") city = contact.get("city_name", "") @@ -190,7 +190,7 @@ class OrgToDomainsScanner(Scanner): if not all([address, city, zip_code, country]): return None - return PhysicalAddress( + return Location( address=address, city=city, zip=zip_code, country=country ) diff --git a/flowsint-transforms/src/flowsint_transforms/organization/to_infos.py b/flowsint-transforms/src/flowsint_transforms/organization/to_infos.py index db3dd15..24d13e9 100644 --- a/flowsint-transforms/src/flowsint_transforms/organization/to_infos.py +++ b/flowsint-transforms/src/flowsint_transforms/organization/to_infos.py @@ -60,12 +60,12 @@ class OrgToInfosScanner(Scanner): try: # Extract siege data siege = company.get("siege", {}) - # Create PhysicalAddress for siege_geo_adresse if coordinates exist + # Create Location for siege_geo_adresse if coordinates exist siege_geo_adresse = None if siege.get("latitude") and siege.get("longitude"): - from flowsint_types.address import PhysicalAddress + from flowsint_types.address import Location - siege_geo_adresse = PhysicalAddress( + siege_geo_adresse = Location( address=siege.get("adresse", ""), city=siege.get("libelle_commune", ""), country="FR", # SIRENE is French registry @@ -130,7 +130,6 @@ class OrgToInfosScanner(Scanner): date_mise_a_jour_rne=company.get("date_mise_a_jour_rne"), # Legal information nature_juridique=company.get("nature_juridique"), - etat_administratif=company.get("etat_administratif"), statut_diffusion=company.get("statut_diffusion"), # Siege (Headquarters) information siege_activite_principale=siege.get("activite_principale"), @@ -293,9 +292,7 @@ class OrgToInfosScanner(Scanner): date_mise_a_jour_insee=org.date_mise_a_jour_insee, date_mise_a_jour_rne=org.date_mise_a_jour_rne, nature_juridique=org.nature_juridique, - etat_administratif=org.etat_administratif, statut_diffusion=org.statut_diffusion, - caption=org.name, type="organization", ) @@ -312,7 +309,7 @@ class OrgToInfosScanner(Scanner): if org.dirigeants: for dirigeant in org.dirigeants: self.create_node( - "Individual", + "individual", "full_name", dirigeant.full_name, first_name=dirigeant.first_name, @@ -324,7 +321,7 @@ class OrgToInfosScanner(Scanner): ) self.create_relationship( - "Organization", + "organization", "org_id", org_key, "Individual", @@ -336,12 +333,12 @@ class OrgToInfosScanner(Scanner): f"{org.name}: HAS_LEADER -> {dirigeant.full_name}" ) - # Add siege address as PhysicalAddress node if available + # Add siege address as Location node if available if org.siege_geo_adresse: address = org.siege_geo_adresse address_key = f"{address.address}_{address.city}_{address.country}" self.create_node( - "PhysicalAddress", + "location", "address_id", address_key, address=address.address, @@ -356,10 +353,10 @@ class OrgToInfosScanner(Scanner): ) self.create_relationship( - "Organization", + "organization", "org_id", org_key, - "PhysicalAddress", + "location", "address_id", address_key, "HAS_ADDRESS", @@ -368,11 +365,11 @@ class OrgToInfosScanner(Scanner): f"{org.name}: HAS_ADDRESS -> {address.address}, {address.city}" ) - # Add siege location as Location node if coordinates are available but no PhysicalAddress + # Add siege location as Location node if coordinates are available but no location elif org.siege_latitude and org.siege_longitude: location_key = f"{org.siege_latitude}_{org.siege_longitude}" self.create_node( - "Location", + "location", "location_id", location_key, latitude=float(org.siege_latitude), @@ -387,7 +384,7 @@ class OrgToInfosScanner(Scanner): ) self.create_relationship( - "Organization", + "organization", "org_id", org_key, "Location", diff --git a/flowsint-types/src/flowsint_types/__init__.py b/flowsint-types/src/flowsint_types/__init__.py index 6d7cf3d..2cfc2ac 100644 --- a/flowsint-types/src/flowsint_types/__init__.py +++ b/flowsint-types/src/flowsint_types/__init__.py @@ -2,7 +2,7 @@ Flowsint Types - Pydantic models for flowsint """ -from .address import PhysicalAddress +from .address import Location from .affiliation import Affiliation from .alias import Alias from .asn import ASN @@ -43,7 +43,7 @@ __version__ = "0.1.0" __author__ = "EliottElek " __all__ = [ - "PhysicalAddress", + "Location", "Affiliation", "Alias", "ASN", diff --git a/flowsint-types/src/flowsint_types/address.py b/flowsint-types/src/flowsint_types/address.py index 91621f2..8c78534 100644 --- a/flowsint-types/src/flowsint_types/address.py +++ b/flowsint-types/src/flowsint_types/address.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field from typing import Optional -class PhysicalAddress(BaseModel): +class Location(BaseModel): """Represents a physical address with geographical coordinates.""" address: str = Field(..., description="Street address", title="Street Address") diff --git a/flowsint-types/src/flowsint_types/individual.py b/flowsint-types/src/flowsint_types/individual.py index 469cfe4..6d2362d 100644 --- a/flowsint-types/src/flowsint_types/individual.py +++ b/flowsint-types/src/flowsint_types/individual.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field from typing import Optional, Literal, List -from .address import PhysicalAddress +from .address import Location class Individual(BaseModel): @@ -30,7 +30,7 @@ class Individual(BaseModel): birth_date: Optional[str] = Field( None, description="Date of birth", title="Date of Birth" ) - birth_place: Optional[PhysicalAddress] = Field( + birth_place: Optional[Location] = Field( None, description="Place of birth", title="Birth Place" ) age: Optional[int] = Field(None, description="Current age", title="Age") @@ -81,7 +81,7 @@ class Individual(BaseModel): death_date: Optional[str] = Field( None, description="Date of death (if applicable)", title="Death Date" ) - death_place: Optional[PhysicalAddress] = Field( + death_place: Optional[Location] = Field( None, description="Place of death (if applicable)", title="Death Place" ) cause_of_death: Optional[str] = Field( @@ -102,10 +102,10 @@ class Individual(BaseModel): ) # Location Information - current_address: Optional[PhysicalAddress] = Field( + current_address: Optional[Location] = Field( None, description="Current residential address", title="Current Address" ) - previous_addresses: Optional[List[PhysicalAddress]] = Field( + previous_addresses: Optional[List[Location]] = Field( None, description="Previous known addresses", title="Previous Addresses" ) nationality: Optional[str] = Field( @@ -176,7 +176,7 @@ class Individual(BaseModel): employer: Optional[str] = Field( None, description="Current employer", title="Employer" ) - employer_address: Optional[PhysicalAddress] = Field( + employer_address: Optional[Location] = Field( None, description="Employer's address", title="Employer Address" ) job_title: Optional[str] = Field( diff --git a/flowsint-types/src/flowsint_types/organization.py b/flowsint-types/src/flowsint_types/organization.py index e756222..df8cf3f 100644 --- a/flowsint-types/src/flowsint_types/organization.py +++ b/flowsint-types/src/flowsint_types/organization.py @@ -1,7 +1,7 @@ from pydantic import BaseModel, Field from typing import Any, Optional, List from .individual import Individual -from .address import PhysicalAddress +from .address import Location class Organization(BaseModel): @@ -157,12 +157,12 @@ class Organization(BaseModel): siege_est_siege: Optional[bool] = Field( None, description="Siege is headquarters", title="Is Headquarters" ) - siege_etat_adminiAnyatif: Optional[Any] = Field( + siege_etat_administratif: Optional[Any] = Field( None, - description="Siege adminiAnyative status", - title="Headquarters AdminiAnyative Status", + description="Siege administratif status", + title="Headquarters administratif Status", ) - siege_geo_adresse: Optional[PhysicalAddress] = Field( + siege_geo_adresse: Optional[Location] = Field( None, description="Siege geocoded address", title="Headquarters Geocoded Address",