From 1d6ee46fb22d964473c28fafe7b7bf4ed7e8bf4f Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Thu, 4 Sep 2025 14:32:17 +0200 Subject: [PATCH] feat: global settings, prototype of nodes merging --- flowsint-api/app/api/routes/chat.py | 1 + flowsint-api/app/api/routes/flows.py | 5 +- flowsint-api/app/api/routes/sketches.py | 196 ++++- flowsint-app/package.json | 1 + flowsint-app/src/renderer/index.html | 4 +- .../src/renderer/src/api/README-query-keys.md | 234 ++++++ .../renderer/src/api/query-keys-examples.ts | 216 ++++++ .../src/renderer/src/api/query-keys.ts | 91 +++ .../src/renderer/src/api/sketch-service.ts | 12 + ...nsfrom-service.ts => transform-service.ts} | 0 .../src/components/analyses/analyses-list.tsx | 19 +- .../components/analyses/analysis-editor.tsx | 11 +- .../dashboard/investigation-cards.tsx | 21 +- .../dashboard/investigation-sketches.tsx | 6 +- .../src/components/dashboard/main-chart.tsx | 289 +++++++ .../components/dashboard/section-cards.tsx | 101 +++ .../src/components/flows/controls.tsx | 4 +- .../renderer/src/components/flows/editor.tsx | 99 ++- .../src/components/flows/flow-name-panel.tsx | 71 +- .../src/components/flows/flow-sheet.tsx | 4 +- .../src/components/flows/new-flow.tsx | 42 +- .../src/components/flows/scanner-data.tsx | 57 -- .../src/components/graphs/add-item-dialog.tsx | 3 +- .../src/components/graphs/context-menu.tsx | 2 +- .../src/components/graphs/create-relation.tsx | 235 +++--- .../details-panel/node-editor-modal.tsx | 81 +- .../graphs/details-panel/relationships.tsx | 2 +- .../src/components/graphs/draggable-item.tsx | 2 +- .../src/components/graphs/force-controls.tsx | 100 --- .../src/components/graphs/global-settings.tsx | 113 --- .../src/components/graphs/graph-main.tsx | 4 +- .../src/components/graphs/graph-settings.tsx | 117 --- .../src/components/graphs/graph-viewer.tsx | 113 ++- .../renderer/src/components/graphs/index.tsx | 14 +- .../renderer/src/components/graphs/lasso.tsx | 6 +- .../components/graphs/launch-transform.tsx | 2 +- .../src/components/graphs/merge-nodes.tsx | 176 +++++ .../src/components/graphs/minimap.tsx | 147 ++++ .../src/components/graphs/new-sketch.tsx | 132 ++-- .../src/components/graphs/node-actions.tsx | 3 +- .../graphs/selected-items-panel.tsx | 2 +- .../src/components/graphs/settings.tsx | 702 ++++++++++++++++++ .../src/components/graphs/toolbar.tsx | 42 +- .../investigations/investigation-list.tsx | 301 ++++++-- .../investigations/new-investigation.tsx | 63 +- .../components/investigations/sketch-list.tsx | 3 +- .../layout/investigation-selector.tsx | 3 +- .../src/components/layout/log-panel.tsx | 22 +- .../src/components/layout/sketch-selector.tsx | 7 +- .../src/components/layout/top-navbar.tsx | 35 +- .../src/components/table/nodes-view.tsx | 195 +++-- .../src/renderer/src/hooks/use-chat.ts | 9 +- .../src/renderer/src/hooks/use-events.ts | 3 +- .../src/renderer/src/hooks/use-launch-flow.ts | 22 +- .../src/hooks/use-launch-transform.ts | 2 +- flowsint-app/src/renderer/src/lib/utils.ts | 31 +- .../routes/_auth.dashboard.flows.index.tsx | 2 +- .../src/routes/_auth.dashboard.index.tsx | 28 +- ...estigations.$investigationId.$type.$id.tsx | 16 +- ....investigations.$investigationId.index.tsx | 152 ++-- .../src/routes/_auth.dashboard.vault.tsx | 7 +- .../src/stores/graph-general-store.ts | 47 -- .../src/stores/graph-settings-store.ts | 426 +++++++---- .../src/renderer/src/stores/graph-store.ts | 4 + flowsint-app/src/renderer/src/types/graph.ts | 15 +- .../src/renderer/src/types/transform.ts | 8 - flowsint-app/yarn.lock | 5 + flowsint-core/poetry.lock | 33 +- flowsint-core/pyproject.toml | 1 + .../src/flowsint_core/core/orchestrator.py | 2 +- flowsint-core/src/flowsint_core/tasks/flow.py | 4 +- .../src/flowsint_core/tests/orchestrator.py | 22 +- .../flowsint_transforms/social/to_maigret.py | 6 +- .../flowsint_transforms/social/to_sherlock.py | 4 +- .../flowsint_transforms/website/to_text.py | 4 +- test.js | 18 + 76 files changed, 3651 insertions(+), 1331 deletions(-) create mode 100644 flowsint-app/src/renderer/src/api/README-query-keys.md create mode 100644 flowsint-app/src/renderer/src/api/query-keys-examples.ts create mode 100644 flowsint-app/src/renderer/src/api/query-keys.ts rename flowsint-app/src/renderer/src/api/{transfrom-service.ts => transform-service.ts} (100%) create mode 100644 flowsint-app/src/renderer/src/components/dashboard/main-chart.tsx create mode 100644 flowsint-app/src/renderer/src/components/dashboard/section-cards.tsx delete mode 100644 flowsint-app/src/renderer/src/components/flows/scanner-data.tsx delete mode 100644 flowsint-app/src/renderer/src/components/graphs/force-controls.tsx delete mode 100644 flowsint-app/src/renderer/src/components/graphs/global-settings.tsx delete mode 100644 flowsint-app/src/renderer/src/components/graphs/graph-settings.tsx create mode 100644 flowsint-app/src/renderer/src/components/graphs/merge-nodes.tsx create mode 100644 flowsint-app/src/renderer/src/components/graphs/minimap.tsx create mode 100644 flowsint-app/src/renderer/src/components/graphs/settings.tsx delete mode 100644 flowsint-app/src/renderer/src/stores/graph-general-store.ts create mode 100644 test.js diff --git a/flowsint-api/app/api/routes/chat.py b/flowsint-api/app/api/routes/chat.py index b7fd1cb..f13c1ac 100644 --- a/flowsint-api/app/api/routes/chat.py +++ b/flowsint-api/app/api/routes/chat.py @@ -83,6 +83,7 @@ def get_chats_by_investigation( .filter( Chat.investigation_id == investigation_id, Chat.owner_id == current_user.id ) + .order_by(Chat.created_at.asc()) .all() ) diff --git a/flowsint-api/app/api/routes/flows.py b/flowsint-api/app/api/routes/flows.py index 2c5bed9..7eadfd2 100644 --- a/flowsint-api/app/api/routes/flows.py +++ b/flowsint-api/app/api/routes/flows.py @@ -6,7 +6,7 @@ from datetime import datetime from flowsint_core.utils import extract_input_schema_flow from flowsint_core.core.registry import TransformRegistry from flowsint_core.core.celery import celery -from flowsint_types import Domain, Phrase, Ip, SocialProfile, Organization, Email +from flowsint_types import Domain, Phrase, Ip, SocialProfile, Organization, Email, Phone from flowsint_core.core.types import Node, Edge, FlowStep, FlowBranch from sqlalchemy.orm import Session from flowsint_core.core.postgre_db import get_db @@ -102,6 +102,7 @@ async def get_material_list(): extract_input_schema_flow(Domain), extract_input_schema_flow(Website), extract_input_schema_flow(Ip), + extract_input_schema_flow(Phone), extract_input_schema_flow(ASN), extract_input_schema_flow(CIDR), extract_input_schema_flow(SocialProfile), @@ -172,7 +173,7 @@ def update_flow( flow = db.query(Flow).filter(Flow.id == flow_id).first() if not flow: raise HTTPException(status_code=404, detail="flow not found") - update_data = payload.dict(exclude_unset=True) + update_data = payload.model_dump(exclude_unset=True) for key, value in update_data.items(): print(f"only update {key}") if key == "category": diff --git a/flowsint-api/app/api/routes/sketches.py b/flowsint-api/app/api/routes/sketches.py index e3712a4..79d5e7f 100644 --- a/flowsint-api/app/api/routes/sketches.py +++ b/flowsint-api/app/api/routes/sketches.py @@ -17,6 +17,45 @@ from app.api.deps import get_current_user router = APIRouter() +class NodeData(BaseModel): + label: str = Field(default="Node", description="Label/name of the node") + color: str = Field(default="Node", description="Color of the node") + type: str = Field(default="Node", description="Type of the node") + # Add any other specific data fields that might be common across nodes + + class Config: + extra = "allow" # Accept any additional fields + + +class NodeInput(BaseModel): + type: str = Field(..., description="Type of the node") + data: NodeData = Field( + default_factory=NodeData, description="Additional data for the node" + ) + + +def dict_to_cypher_props(props: dict, prefix: str = "") -> str: + return ", ".join(f"{key}: ${prefix}{key}" for key in props) + + +class NodeDeleteInput(BaseModel): + nodeIds: List[str] + + +class NodeEditInput(BaseModel): + nodeId: str + data: NodeData = Field( + default_factory=NodeData, description="Updated data for the node" + ) + + +class NodeMergeInput(BaseModel): + id: str + data: NodeData = Field( + default_factory=NodeData, description="Updated data for the node" + ) + + @router.post("/create", response_model=SketchRead, status_code=status.HTTP_201_CREATED) def create_sketch( data: SketchCreate, @@ -54,14 +93,19 @@ def get_sketch_by_id( @router.put("/{id}", response_model=SketchRead) def update_sketch( id: UUID, - data: SketchUpdate, + payload: SketchUpdate, db: Session = Depends(get_db), current_user: Profile = Depends(get_current_user), ): - sketch = db.query(Sketch).filter(Sketch.owner_id == current_user.id).get(id) + sketch = ( + db.query(Sketch) + .filter(Sketch.owner_id == current_user.id) + .filter(Sketch.id == id) + .first() + ) if not sketch: raise HTTPException(status_code=404, detail="Sketch not found") - for key, value in data.dict(exclude_unset=True).items(): + for key, value in payload.model_dump(exclude_unset=True).items(): setattr(sketch, key, value) db.commit() db.refresh(sketch) @@ -177,27 +221,6 @@ async def get_sketch_nodes( return {"nds": nodes, "rls": rels} -class NodeData(BaseModel): - label: str = Field(default="Node", description="Label/name of the node") - color: str = Field(default="Node", description="Color of the node") - type: str = Field(default="Node", description="Type of the node") - # Add any other specific data fields that might be common across nodes - - class Config: - extra = "allow" # Accept any additional fields - - -class NodeInput(BaseModel): - type: str = Field(..., description="Type of the node") - data: NodeData = Field( - default_factory=NodeData, description="Additional data for the node" - ) - - -def dict_to_cypher_props(props: dict, prefix: str = "") -> str: - return ", ".join(f"{key}: ${prefix}{key}" for key in props) - - @router.post("/{sketch_id}/nodes/add") def add_node( sketch_id: str, node: NodeInput, current_user: Profile = Depends(get_current_user) @@ -254,8 +277,8 @@ def add_node( class RelationInput(BaseModel): - source: Any - target: Any + source: str + target: str type: Literal["one-way", "two-way"] label: str = "RELATED_TO" # Optionnel : nom de la relation @@ -275,8 +298,8 @@ def add_edge( """ params = { - "from_id": relation.source["id"], - "to_id": relation.target["id"], + "from_id": relation.source, + "to_id": relation.target, "sketch_id": sketch_id, } @@ -295,17 +318,6 @@ def add_edge( } -class NodeDeleteInput(BaseModel): - nodeIds: List[str] - - -class NodeEditInput(BaseModel): - nodeId: str - data: NodeData = Field( - default_factory=NodeData, description="Updated data for the node" - ) - - @router.put("/{sketch_id}/nodes/edit") def edit_node( sketch_id: str, @@ -394,6 +406,112 @@ def delete_nodes( return {"status": "nodes deleted", "count": len(nodes.nodeIds)} +@router.post("/{sketch_id}/nodes/merge") +def merge_nodes( + sketch_id: str, + oldNodes: List[str], + newNode: NodeMergeInput, + db: Session = Depends(get_db), + current_user: Profile = Depends(get_current_user), +): + # 1. Vérifier le sketch + sketch = db.query(Sketch).filter(Sketch.id == sketch_id).first() + if not sketch: + raise HTTPException(status_code=404, detail="Sketch not found") + + oldNodeIds = [id for id in oldNodes] + + # 2. Préparer le node unique (utiliser nodeId) + node_id = getattr(newNode, "id", None) + if not node_id: + raise HTTPException(status_code=400, detail="newNode.id is required") + + properties = {} + if newNode.data: + flattened_data = flatten(newNode.data.dict()) + properties.update(flattened_data) + + cypher_props = dict_to_cypher_props(properties) + node_type = getattr(newNode, "type", "Node") + + # 3. Créer ou merger le nouveau node + create_query = f""" + MERGE (new:`{node_type}` {{nodeId: $nodeId}}) + SET new += $nodeData + RETURN elementId(new) as newElementId + """ + try: + result = neo4j_connection.query( + create_query, {"nodeId": node_id, "nodeData": cypher_props} + ) + new_node_element_id = result[0]["newElementId"] + except Exception as e: + print(f"Error creating/merging new node: {e}") + raise HTTPException(status_code=500, detail="Failed to create new node") + + # 4. Récupérer tous les types de relations des oldNodes + rel_types_query = """ + MATCH (old) + WHERE elementId(old) IN $oldNodeIds AND old.sketch_id = $sketch_id + MATCH (old)-[r]-() + RETURN DISTINCT type(r) AS relType + """ + try: + rel_types_result = neo4j_connection.query( + rel_types_query, {"oldNodeIds": oldNodeIds, "sketch_id": sketch_id} + ) + rel_types = [row["relType"] for row in rel_types_result] or [] + except Exception as e: + print(f"Error fetching relation types: {e}") + raise HTTPException(status_code=500, detail="Failed to fetch relation types") + + # 5. Construire la query pour copier les relations + blocks = [] + for rel_type in rel_types: + block = f""" + // Relations entrantes + MATCH (new) WHERE elementId(new) = $newElementId + MATCH (old) WHERE elementId(old) IN $oldNodeIds + OPTIONAL MATCH (src)-[r:`{rel_type}`]->(old) + WITH src, new, r WHERE src IS NOT NULL + MERGE (src)-[newRel:`{rel_type}`]->(new) + ON CREATE SET newRel = r + ON MATCH SET newRel += r + WITH DISTINCT new + + // Relations sortantes + MATCH (new) WHERE elementId(new) = $newElementId + MATCH (old) WHERE elementId(old) IN $oldNodeIds + OPTIONAL MATCH (old)-[r:`{rel_type}`]->(dst) + WITH dst, new, r WHERE dst IS NOT NULL + MERGE (new)-[newRel2:`{rel_type}`]->(dst) + ON CREATE SET newRel2 = r + ON MATCH SET newRel2 += r + WITH DISTINCT new + """ + blocks.append(block) + + # 6. Supprimer les anciens nodes + delete_query = """ + MATCH (old) + WHERE elementId(old) IN $oldNodeIds + DETACH DELETE old + """ + + full_query = "\n".join(blocks) + delete_query + + # 7. Exécuter la query + try: + neo4j_connection.query( + full_query, {"newElementId": new_node_element_id, "oldNodeIds": oldNodeIds} + ) + except Exception as e: + print(f"Node merging error: {e}") + raise HTTPException(status_code=500, detail="Failed to merge node relations") + + return {"status": "nodes merged", "count": len(oldNodeIds)} + + @router.get("/{sketch_id}/nodes/{node_id}") def get_related_nodes( sketch_id: str, diff --git a/flowsint-app/package.json b/flowsint-app/package.json index bcea71c..152007f 100644 --- a/flowsint-app/package.json +++ b/flowsint-app/package.json @@ -33,6 +33,7 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^3.0.0", "@hookform/resolvers": "^5.0.1", + "@lukemorales/query-key-factory": "^1.3.4", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-aspect-ratio": "^1.1.7", diff --git a/flowsint-app/src/renderer/index.html b/flowsint-app/src/renderer/index.html index 153b424..c41e657 100644 --- a/flowsint-app/src/renderer/index.html +++ b/flowsint-app/src/renderer/index.html @@ -1,11 +1,11 @@ - + --> diff --git a/flowsint-app/src/renderer/src/api/README-query-keys.md b/flowsint-app/src/renderer/src/api/README-query-keys.md new file mode 100644 index 0000000..bd0b0b9 --- /dev/null +++ b/flowsint-app/src/renderer/src/api/README-query-keys.md @@ -0,0 +1,234 @@ +# Query Key Factory Implementation + +This project now uses the [@lukemorales/query-key-factory](https://tanstack.com/query/v4/docs/framework/react/community/lukemorales-query-key-factory) library to manage TanStack Query keys in a type-safe and organized way. + +## Overview + +Query Key Factory provides: +- **Type-safe query keys** with auto-completion +- **Centralized key management** - all keys in one place +- **Easy invalidation** - no more hardcoded key strings +- **Consistent patterns** across your application + +## Files structure + +``` +src/api/ +├── query-keys.ts # Main query key definitions +├── query-keys-examples.ts # Usage examples and custom hooks +└── README-query-keys.md # This documentation +``` + +## How to use + +### 1. Basic query usage + +```tsx +import { useQuery } from '@tanstack/react-query' +import { queryKeys } from '@/api/query-keys' +import { investigationService } from '@/api/investigation-service' + +// Before (hardcoded keys) +const { data } = useQuery({ + queryKey: ['investigations', 'list'], + queryFn: investigationService.get, +}) + +// After (using query key factory) +const { data } = useQuery({ + queryKey: queryKeys.investigations.list, + queryFn: investigationService.get, +}) +``` + +### 2. Dynamic Keys with Parameters + +```tsx +// Before +const { data } = useQuery({ + queryKey: ['investigations', investigationId], + queryFn: () => investigationService.getById(investigationId), +}) + +// After +const { data } = useQuery({ + queryKey: queryKeys.investigations.detail(investigationId), + queryFn: () => investigationService.getById(investigationId), +}) +``` + +### 3. Mutation with Invalidation + +```tsx +import { useMutation, useQueryClient } from '@tanstack/react-query' + +const createAnalysisMutation = useMutation({ + mutationFn: analysisService.create, + onSuccess: (data, variables) => { + const queryClient = useQueryClient() + + // Invalidate specific queries + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.analyses(variables.investigation_id) + }) + + // Invalidate general lists + queryClient.invalidateQueries({ + queryKey: queryKeys.analyses.list + }) + }, +}) +``` + +### 4. Cache Updates + +```tsx +const updateAnalysisMutation = useMutation({ + mutationFn: analysisService.update, + onSuccess: (data, variables) => { + const queryClient = useQueryClient() + + // Update cache directly for better UX + queryClient.setQueryData( + queryKeys.analyses.detail(variables.analysisId), + data + ) + + // Invalidate related queries + queryClient.invalidateQueries({ + queryKey: queryKeys.analyses.byInvestigation(data.investigation_id) + }) + }, +}) +``` + +## Available Query Keys + +### Auth +- `queryKeys.auth.session` +- `queryKeys.auth.currentUser` + +### Investigations +- `queryKeys.investigations.list` +- `queryKeys.investigations.detail(investigationId)` +- `queryKeys.investigations.sketches(investigationId)` +- `queryKeys.investigations.analyses(investigationId)` +- `queryKeys.investigations.flows(investigationId)` + +### Sketches/Graphs +- `queryKeys.sketches.list` +- `queryKeys.sketches.detail(sketchId)` +- `queryKeys.sketches.byInvestigation(investigationId)` +- `queryKeys.sketches.graph(investigationId, sketchId)` +- `queryKeys.sketches.types` + +### Analyses +- `queryKeys.analyses.list` +- `queryKeys.analyses.detail(analysisId)` +- `queryKeys.analyses.byInvestigation(investigationId)` + +### Flows +- `queryKeys.flows.list` +- `queryKeys.flows.detail(flowId)` +- `queryKeys.flows.byInvestigation(investigationId)` + +### Chats +- `queryKeys.chats.list` +- `queryKeys.chats.detail(chatId)` +- `queryKeys.chats.byInvestigation(investigationId)` +- `queryKeys.chats.messages(chatId)` + +### API Keys +- `queryKeys.keys.list` +- `queryKeys.keys.detail(keyId)` + +### Logs/Events +- `queryKeys.logs.bySketch(sketchId)` + +### Action Items +- `queryKeys.actionItems` + +### Scans +- `queryKeys.scans.list` +- `queryKeys.scans.detail(scanId)` + +### Transforms +- `queryKeys.transforms.list` +- `queryKeys.transforms.detail(transformId)` + +## Migration Guide + +### Step 1: Update Existing Hooks + +Find your existing `useQuery` calls and replace hardcoded keys: + +```tsx +// Before +queryKey: ['investigations', 'list'] + +// After +queryKey: queryKeys.investigations.list +``` + +### Step 2: Update Mutations + +Replace hardcoded invalidation keys: + +```tsx +// Before +queryClient.invalidateQueries({ queryKey: ['investigations'] }) + +// After +queryClient.invalidateQueries({ queryKey: queryKeys.investigations.list }) +``` + +### Step 3: Update Cache Operations + +Replace hardcoded keys in `setQueryData` and `removeQueries`: + +```tsx +// Before +queryClient.setQueryData(['analyses', analysisId], data) + +// After +queryClient.setQueryData(queryKeys.analyses.detail(analysisId), data) +``` + +## Benefits + +1. **Type Safety**: TypeScript will catch typos and provide autocomplete +2. **Refactoring**: Change a key structure in one place +3. **Consistency**: All keys follow the same pattern +4. **Maintainability**: Easy to see all available keys +5. **IDE Support**: Better autocomplete and IntelliSense + +## Best Practices + +1. **Always use the factory**: Don't hardcode query keys +2. **Group related keys**: Use the logical grouping provided +3. **Invalidate properly**: Use the exact key for invalidation +4. **Update cache**: Use `setQueryData` with the factory keys +5. **Consistent naming**: Follow the established patterns + +## Troubleshooting + +### Common Issues + +1. **Type errors**: Make sure you're calling the key function with parameters +2. **Invalidation not working**: Ensure you're using the exact same key structure +3. **Cache updates failing**: Verify the key matches the query key exactly + +### Debug Tips + +1. **Console log keys**: `console.log(queryKeys.investigations.list)` +2. **Check key structure**: Compare generated keys with existing ones +3. **Verify imports**: Ensure you're importing from the correct path + +## Examples + +See `query-keys-examples.ts` for comprehensive usage examples including: +- Basic queries +- Mutations with invalidation +- Cache updates +- Complex invalidation patterns +- Prefetching strategies diff --git a/flowsint-app/src/renderer/src/api/query-keys-examples.ts b/flowsint-app/src/renderer/src/api/query-keys-examples.ts new file mode 100644 index 0000000..4e8b732 --- /dev/null +++ b/flowsint-app/src/renderer/src/api/query-keys-examples.ts @@ -0,0 +1,216 @@ +// Examples of how to use the Query Key Factory with your existing hooks +// This file demonstrates the proper usage patterns + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { queryKeys } from './query-keys' +import { analysisService } from './analysis-service' +import { investigationService } from './investigation-service' +import { sketchService } from './sketch-service' +import { chatCRUDService } from './chat-service' +import { KeyService } from './key-service' +import { logService } from './log-service' +import { scanService } from './scan-service' +import { transformService } from './transform-service' + +// Example 1: Using the query keys directly +export const useInvestigationsList = () => { + return useQuery({ + queryKey: queryKeys.investigations.list, + queryFn: investigationService.get, + }) +} + +export const useInvestigationDetail = (investigationId: string) => { + return useQuery({ + queryKey: queryKeys.investigations.detail(investigationId), + queryFn: () => investigationService.getById(investigationId), + enabled: !!investigationId, + }) +} + +export const useInvestigationAnalyses = (investigationId: string) => { + return useQuery({ + queryKey: queryKeys.investigations.analyses(investigationId), + queryFn: () => analysisService.getByInvestigationId(investigationId), + enabled: !!investigationId, + }) +} + +// Example 2: Using the individual key groups +export const useSketchesList = () => { + return useQuery({ + queryKey: queryKeys.sketches.list, + queryFn: sketchService.get, + }) +} + +export const useSketchDetail = (sketchId: string) => { + return useQuery({ + queryKey: queryKeys.sketches.detail(sketchId), + queryFn: () => sketchService.getById(sketchId), + enabled: !!sketchId, + }) +} + +export const useSketchGraph = (investigationId: string, sketchId: string) => { + return useQuery({ + queryKey: queryKeys.sketches.graph(investigationId, sketchId), + queryFn: () => sketchService.getById(sketchId), + enabled: !!investigationId && !!sketchId, + }) +} + +// Example 3: Using with mutations and invalidation +export const useCreateAnalysis = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { title: string; investigation_id: string; content: any }) => { + return analysisService.create(JSON.stringify(data)) + }, + onSuccess: (data, variables) => { + // Invalidate the specific investigation's analyses + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.analyses(variables.investigation_id) + }) + + // Also invalidate the general analyses list + queryClient.invalidateQueries({ + queryKey: queryKeys.analyses.list + }) + }, + }) +} + +export const useUpdateAnalysis = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ analysisId, data }: { analysisId: string; data: any }) => { + return analysisService.update(analysisId, JSON.stringify(data)) + }, + onSuccess: (data, variables) => { + // Update the cache directly for better UX + queryClient.setQueryData( + queryKeys.analyses.detail(variables.analysisId), + data + ) + + // Invalidate related queries + queryClient.invalidateQueries({ + queryKey: queryKeys.analyses.byInvestigation(data.investigation_id) + }) + }, + }) +} + +// Example 4: Using with chat functionality +export const useChatDetail = (chatId: string) => { + return useQuery({ + queryKey: queryKeys.chats.detail(chatId), + queryFn: () => chatCRUDService.getById(chatId), + enabled: !!chatId, + }) +} + +export const useChatsByInvestigation = (investigationId: string) => { + return useQuery({ + queryKey: queryKeys.chats.byInvestigation(investigationId), + queryFn: () => chatCRUDService.getByInvestigationId(investigationId), + enabled: !!investigationId, + }) +} + +// Example 5: Using with logs and events +export const useLogsBySketch = (sketchId: string) => { + return useQuery({ + queryKey: queryKeys.logs.bySketch(sketchId), + queryFn: () => logService.get(sketchId), + enabled: !!sketchId, + staleTime: 30_000, // 30 seconds + }) +} + +// Example 6: Using with API keys +export const useKeysList = () => { + return useQuery({ + queryKey: queryKeys.keys.list, + queryFn: KeyService.get, + }) +} + +export const useKeyDetail = (keyId: string) => { + return useQuery({ + queryKey: queryKeys.keys.detail(keyId), + queryFn: () => KeyService.getById(keyId), + enabled: !!keyId, + }) +} + +// Example 7: Using with scans +export const useScanDetail = (scanId: string) => { + return useQuery({ + queryKey: queryKeys.scans.detail(scanId), + queryFn: () => scanService.get(scanId), + enabled: !!scanId, + }) +} + +// Example 8: Using with transforms +export const useTransformsList = () => { + return useQuery({ + queryKey: queryKeys.transforms.list, + queryFn: transformService.get, + }) +} + +// Example 9: Complex invalidation patterns +export const useDeleteInvestigation = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: investigationService.delete, + onSuccess: (data, variables) => { + // Invalidate all investigation-related queries + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.list + }) + + // Invalidate all related data + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.detail(variables) + }) + + // Clear related caches + queryClient.removeQueries({ + queryKey: queryKeys.investigations.analyses(variables) + }) + + queryClient.removeQueries({ + queryKey: queryKeys.investigations.sketches(variables) + }) + + queryClient.removeQueries({ + queryKey: queryKeys.investigations.flows(variables) + }) + }, + }) +} + +// Example 10: Prefetching with query keys +export const usePrefetchInvestigation = () => { + const queryClient = useQueryClient() + + return (investigationId: string) => { + queryClient.prefetchQuery({ + queryKey: queryKeys.investigations.detail(investigationId), + queryFn: () => investigationService.getById(investigationId), + }) + + // Also prefetch related data + queryClient.prefetchQuery({ + queryKey: queryKeys.investigations.analyses(investigationId), + queryFn: () => analysisService.getByInvestigationId(investigationId), + }) + } +} diff --git a/flowsint-app/src/renderer/src/api/query-keys.ts b/flowsint-app/src/renderer/src/api/query-keys.ts new file mode 100644 index 0000000..5424f9a --- /dev/null +++ b/flowsint-app/src/renderer/src/api/query-keys.ts @@ -0,0 +1,91 @@ +// Simple query key factory that actually works +export const queryKeys = { + // Auth related queries + auth: { + session: ['auth', 'session'], + currentUser: ['auth', 'currentUser'], + }, + + // Investigations + investigations: { + list: ['investigations', 'list'], + detail: (investigationId: string) => ['investigations', investigationId], + sketches: (investigationId: string) => [investigationId, 'sketches'], + analyses: (investigationId: string) => [investigationId, 'analyses'], + flows: (investigationId: string) => [investigationId, 'flows'], + dashboard: ['investigations', 'dashboard'], + selector: (investigationId: string) => ['dashboard', 'selector', investigationId], + }, + + // Sketches/Graphs + sketches: { + list: ['sketches', 'list'], + detail: (sketchId: string) => ['sketches', sketchId], + byInvestigation: (investigationId: string) => [investigationId, 'sketches'], + graph: (investigationId: string, sketchId: string) => [investigationId, 'graph', sketchId], + types: ['sketches', 'types'], + dashboard: (investigationId: string) => ['dashboard', 'investigation', investigationId], + }, + + // Analyses + analyses: { + list: ['analyses', 'list'], + detail: (analysisId: string) => ['analyses', analysisId], + byInvestigation: (investigationId: string) => [investigationId, 'analyses'], + dashboard: (investigationId: string) => ['analyses', investigationId], + }, + + // Flows + flows: { + list: ['flows', 'list'], + detail: (flowId: string) => ['flows', flowId], + byInvestigation: (investigationId: string) => [investigationId, 'flows'], + }, + + // Chats + chats: { + list: ['chats', 'list'], + detail: (chatId: string) => ['chats', chatId], + byInvestigation: (investigationId: string) => [investigationId, 'chats'], + messages: (chatId: string) => [chatId, 'messages'], + }, + + // API Keys + keys: { + list: ['keys'], + detail: (keyId: string) => ['keys', keyId], + }, + + // Logs/Events + logs: { + bySketch: (sketchId: string) => [sketchId, 'logs'], + }, + + // Action Items + actionItems: ['actionItems'], + + // Scans + scans: { + list: ['scans', 'list'], + detail: (scanId: string) => ['scans', scanId], + }, + + // Transforms + transforms: { + list: ['transforms', 'list'], + detail: (transformId: string) => ['transforms', transformId], + }, +} + +// Export individual key groups for easier imports +export const authKeys = queryKeys.auth +export const investigationKeys = queryKeys.investigations +export const sketchKeys = queryKeys.sketches +export const analysisKeys = queryKeys.analyses +export const flowKeys = queryKeys.flows +export const chatKeys = queryKeys.chats +export const keyKeys = queryKeys.keys +export const logKeys = queryKeys.logs +export const actionItemKeys = queryKeys.actionItems +export const scanKeys = queryKeys.scans +export const transformKeys = queryKeys.transforms diff --git a/flowsint-app/src/renderer/src/api/sketch-service.ts b/flowsint-app/src/renderer/src/api/sketch-service.ts index fc9d8c4..9be7ebf 100644 --- a/flowsint-app/src/renderer/src/api/sketch-service.ts +++ b/flowsint-app/src/renderer/src/api/sketch-service.ts @@ -40,6 +40,12 @@ export const sketchService = { body: body }); }, + mergeNodes: async (sketchId: string, body: BodyInit): Promise => { + return fetchWithAuth(`/api/sketches/${sketchId}/nodes/merge`, { + method: 'POST', + body: body + }); + }, deleteNodes: async (sketchId: string, body: BodyInit): Promise => { return fetchWithAuth(`/api/sketches/${sketchId}/nodes`, { method: 'DELETE', @@ -62,4 +68,10 @@ export const sketchService = { method: 'GET' }); }, + update: async (sketchId: string, body: BodyInit): Promise => { + return fetchWithAuth(`/api/sketches/${sketchId}`, { + method: 'PUT', + body: body + }); + }, }; \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/api/transfrom-service.ts b/flowsint-app/src/renderer/src/api/transform-service.ts similarity index 100% rename from flowsint-app/src/renderer/src/api/transfrom-service.ts rename to flowsint-app/src/renderer/src/api/transform-service.ts diff --git a/flowsint-app/src/renderer/src/components/analyses/analyses-list.tsx b/flowsint-app/src/renderer/src/components/analyses/analyses-list.tsx index f770d47..dc73bd5 100644 --- a/flowsint-app/src/renderer/src/components/analyses/analyses-list.tsx +++ b/flowsint-app/src/renderer/src/components/analyses/analyses-list.tsx @@ -11,6 +11,7 @@ import { cn } from "@/lib/utils" import { useMutation, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" import { formatDistanceToNow } from "date-fns" +import { queryKeys } from "@/api/query-keys" const AnalysisItem = ({ analysis, active }: { analysis: Analysis, active: boolean }) => { return ( @@ -44,8 +45,8 @@ const AnalysisList = () => { const navigate = useNavigate() // Fetch all analyses for this investigation - const { data: analyses, error, isLoading } = useQuery({ - queryKey: ["analyses", investigationId], + const { data: analyses, isLoading, error } = useQuery({ + queryKey: queryKeys.analyses.byInvestigation(investigationId || ""), queryFn: () => analysisService.getByInvestigationId(investigationId || ""), enabled: !!investigationId, }) @@ -62,7 +63,7 @@ const AnalysisList = () => { ) }, [analyses, searchQuery]) - const createAnalysisMutation = useMutation({ + const createMutation = useMutation({ mutationFn: async () => { const newAnalysis: Partial = { title: "Untitled Analysis", @@ -72,8 +73,8 @@ const AnalysisList = () => { return analysisService.create(JSON.stringify(newAnalysis)) }, onSuccess: async (data) => { - queryClient.invalidateQueries({ queryKey: ["analyses", investigationId] }) - toast.success("New analysis created") + queryClient.invalidateQueries({ queryKey: queryKeys.analyses.byInvestigation(investigationId || "") }) + toast.success("Analysis created successfully") investigationId && navigate({ to: "/dashboard/investigations/$investigationId/$type/$id", params: { investigationId, type: "analysis", id: data.id } }) }, onError: (error) => { @@ -89,8 +90,8 @@ const AnalysisList = () => { variant="ghost" size="icon" className="h-7 w-7" - onClick={() => createAnalysisMutation.mutate()} - disabled={createAnalysisMutation.isPending} + onClick={() => createMutation.mutate()} + disabled={createMutation.isPending} title="Create new analysis" > @@ -136,8 +137,8 @@ const AnalysisList = () => { + +
+
+ {/* Left side nodes */} +
+ {(isReversed ? selectedNodes.slice(1) : selectedNodes.slice(0, -1)).map((node) => ( + + ))} +
+
+
+
+ + {relationType || "IS_RELATED"} + +
-
- - setRelationType(e.target.value)} - required - /> -
-
-
To
-
- {selectedNodes.slice(-1).map((node) => ( - - {getNodeDisplayName(node)} - - ))} -
+ {/* Right side node */} +
+ {(isReversed ? selectedNodes.slice(0, 1) : selectedNodes.slice(-1)).map((node) => ( + + ))}
- - -
-
-
Nodes
-
- {selectedNodes.map((node) => ( - - {getNodeDisplayName(node)} - - ))} -
-
-
- - setRelationType(e.target.value)} - required - /> -
-
-
- +
+
+ - - @@ -143,3 +146,59 @@ export function CreateRelationDialog() { ) } + +interface NodeDisplayCardProps { + node: any + variant?: "default" | "preview" + asRadio?: boolean + radioValue?: string + id?: string +} + +export function NodeDisplayCard({ node, variant = "default", asRadio = false, radioValue, id }: NodeDisplayCardProps) { + const NodeIcon = useIcon(node.data?.type, node.data?.src) + + const getNodeDisplayName = (node: any) => { + return node.data?.label || node.data?.username || node.id + } + + if (variant === "preview") { + return ( +
+
+ +
+ + {getNodeDisplayName(node)} + +
+ ) + } + + if (asRadio) { + return ( + + ) + } + + return ( +
+
+ +
+ + {getNodeDisplayName(node)} + +
+ ) +} diff --git a/flowsint-app/src/renderer/src/components/graphs/details-panel/node-editor-modal.tsx b/flowsint-app/src/renderer/src/components/graphs/details-panel/node-editor-modal.tsx index 693316f..996241e 100644 --- a/flowsint-app/src/renderer/src/components/graphs/details-panel/node-editor-modal.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/details-panel/node-editor-modal.tsx @@ -14,6 +14,8 @@ import { useParams } from "@tanstack/react-router" import { toast } from "sonner" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { useIcon } from "@/hooks/use-icon" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { queryKeys } from "@/api/query-keys" export const NodeEditorModal: React.FC = () => { const currentNode = useGraphStore(state => state.currentNode) @@ -44,10 +46,57 @@ export const NodeEditorModal: React.FC = () => { } }, [currentNode]); - const handleSave = async () => { - if (!currentNode || !sketchId) return; + const queryClient = useQueryClient() - setIsSaving(true); + // Update node mutation + const updateNodeMutation = useMutation({ + mutationFn: async ({ sketchId, updateData }: { sketchId: string, updateData: any }) => { + return sketchService.updateNode(sketchId, JSON.stringify(updateData)) + }, + onSuccess: (result, variables) => { + if (result.status === "node updated" && currentNode) { + // Update the local store + updateNode(currentNode.id, formData) + setCurrentNode({ + ...currentNode, + data: { + ...currentNode.data, + ...formData + } + }) + + // Invalidate related queries + if (sketchId) { + queryClient.invalidateQueries({ + queryKey: queryKeys.sketches.detail(sketchId) + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.sketches.graph(sketchId, sketchId) + }) + // Also invalidate investigation queries that might show sketch data + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.list + }) + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.dashboard + }) + } + + toast.success("Node updated successfully") + setOpenNodeEditorModal(false) + } else { + toast.error("Failed to update node") + } + }, + onError: (error) => { + console.error("Error updating node:", error) + toast.error("Failed to update node. Please try again.") + } + }); + + const handleSave = async () => { + if (!currentNode || !sketchId) return + setIsSaving(true) try { // Prepare the data for the API @@ -56,30 +105,12 @@ export const NodeEditorModal: React.FC = () => { data: formData }; - // Call the API to update the node - const result = await sketchService.updateNode(sketchId, JSON.stringify(updateData)); - - if (result.status === "node updated") { - // Update the local store - updateNode(currentNode.id, formData); - setCurrentNode({ - ...currentNode, - data: { - ...currentNode.data, - ...formData - } - }); - - toast.success("Node updated successfully"); - setOpenNodeEditorModal(false); - } else { - toast.error("Failed to update node"); - } + // Call the mutation + await updateNodeMutation.mutateAsync({ sketchId, updateData }) } catch (error) { - console.error("Error updating node:", error); - toast.error("Failed to update node. Please try again."); + console.error("Error updating node:", error) } finally { - setIsSaving(false); + setIsSaving(false) } }; 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 91e1c5f..ad0dd82 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 @@ -104,7 +104,7 @@ const RelationshipItem = memo(({ node }: { node: GraphNode }) => { className='w-full h-full text-left hover:underline cursor-pointer flex items-center' onClick={handleClick} > - + {node.data.label} diff --git a/flowsint-app/src/renderer/src/components/graphs/draggable-item.tsx b/flowsint-app/src/renderer/src/components/graphs/draggable-item.tsx index 59c66c4..ea18abb 100644 --- a/flowsint-app/src/renderer/src/components/graphs/draggable-item.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/draggable-item.tsx @@ -69,7 +69,7 @@ export const DraggableItem = memo(function DraggableItem({ style={{ borderLeftColor: colorStr }} >
- +

{label}

diff --git a/flowsint-app/src/renderer/src/components/graphs/force-controls.tsx b/flowsint-app/src/renderer/src/components/graphs/force-controls.tsx deleted file mode 100644 index 9e64563..0000000 --- a/flowsint-app/src/renderer/src/components/graphs/force-controls.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useGraphSettingsStore } from '@/stores/graph-settings-store'; -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; -import { Slider } from '../ui/slider'; -import { Label } from '../ui/label'; -import { Zap, Move, Target } from 'lucide-react'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; -import { useCallback } from 'react'; - -const ForceControls = ({ children }: { children: React.ReactNode }) => { - const settings = useGraphSettingsStore(s => s.settings); - const currentPreset = useGraphSettingsStore(s => s.currentPreset); - const updateSetting = useGraphSettingsStore(s => s.updateSetting); - const applyPreset = useGraphSettingsStore(s => s.applyPreset); - const getPresets = useGraphSettingsStore(s => s.getPresets); - const setSettingsModalOpen = useGraphSettingsStore(s => s.setSettingsModalOpen) - - const handleOpenSettingsModal = useCallback(() => { - setSettingsModalOpen(true) - }, [setSettingsModalOpen]) - - const quickControls = [ - { - key: 'd3AlphaDecay', - label: 'Convergence', - icon: , - }, - { - key: 'd3VelocityDecay', - label: 'Friction', - icon: , - }, - { - key: 'cooldownTicks', - label: 'Simulation', - icon: , - } - ]; - - const presets = getPresets(); - - return ( - - -
- {children} -
-
- -
-
Force Presets
- - -
-
Quick Controls
- {quickControls.map(({ key, label, icon }) => { - const setting = settings[key]; - if (!setting) return null; - - return ( -
-
- {icon} - -
- updateSetting(key, values[0])} - min={setting.min} - max={setting.max} - step={setting.step} - className="w-full" - /> -
- ); - })} -
- -
- -
-
-
-
- ); -}; - -export default ForceControls; \ No newline at end of file diff --git a/flowsint-app/src/renderer/src/components/graphs/global-settings.tsx b/flowsint-app/src/renderer/src/components/graphs/global-settings.tsx deleted file mode 100644 index 52555a8..0000000 --- a/flowsint-app/src/renderer/src/components/graphs/global-settings.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Button } from "@/components/ui/button" -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" - -import { useGraphGeneralSettingsStore } from "@/stores/graph-general-store" -import { isMacOS } from "@/components/analyses/editor/utils" - -export default function GlobalSettings() { - const settingsModalOpen = useGraphGeneralSettingsStore(s => s.settingsModalOpen) - const setSettingsModalOpen = useGraphGeneralSettingsStore(s => s.setSettingsModalOpen) - - return ( - - - - General settings - - Make changes to your general settings here. Click save when you're - done. - - -
- Comming soon. -
- - - - - - -
-
- ) -} - -export function KeyboardShortcuts() { - const keyboardShortcutsOpen = useGraphGeneralSettingsStore(s => s.keyboardShortcutsOpen) - const setKeyboardShortcutsOpen = useGraphGeneralSettingsStore(s => s.setKeyboardShortcutsOpen) - - const isMac = isMacOS() - const modKey = isMac ? "⌘" : "Ctrl" - - const shortcuts = [ - { - category: "Navigation & Panels", - items: [ - { key: `${modKey}+L`, description: "Toggle Analysis Panel" }, - { key: `${modKey}+B`, description: "Toggle Panel" }, - { key: `${modKey}+D`, description: "Toggle Console" }, - { key: `${modKey}+J`, description: "Open Command Palette" }, - ] - }, - { - category: "Chat & Assistant", - items: [ - { key: `${modKey}+E`, description: "Toggle Chat Assistant" }, - { key: "Escape", description: "Close Chat Assistant" }, - ] - }, - { - category: "File Operations", - items: [ - { key: `${modKey}+S`, description: "Save (Analysis/Flow)" }, - ] - } - ] - - return ( - - - - Keyboard Shortcuts - - Here is the list of all available keyboard shortcuts. - - -
- {shortcuts.map((category) => ( -
-

- {category.category} -

-
- {category.items.map((item) => ( -
- - {item.description} - - - {item.key} - -
- ))} -
-
- ))} -
- - - - - -
-
- ) -} \ No newline at end of file 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 18f3e6d..c4e68f4 100644 --- a/flowsint-app/src/renderer/src/components/graphs/graph-main.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/graph-main.tsx @@ -2,13 +2,14 @@ import { useGraphStore } from '@/stores/graph-store' import React, { useRef, useCallback } from 'react' import GraphViewer from './graph-viewer' import ContextMenu from './context-menu' +import { useGraphSettingsStore } from '@/stores/graph-settings-store' const GraphMain = () => { const filteredNodes = useGraphStore(s => s.filteredNodes) const filteredEdges = useGraphStore(s => s.filteredEdges) - // const currentNode = useGraphStore(s => s.currentNode) const toggleNodeSelection = useGraphStore(s => s.toggleNodeSelection) const clearSelectedNodes = useGraphStore(s => s.clearSelectedNodes) + const settings = useGraphSettingsStore(s => s.settings) const graphRef = useRef() const containerRef = useRef(null) @@ -61,6 +62,7 @@ const GraphMain = () => { showIcons={true} onGraphRef={handleGraphRef} allowLasso + minimap={settings.general.showMinimap.value} /> {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 deleted file mode 100644 index c852793..0000000 --- a/flowsint-app/src/renderer/src/components/graphs/graph-settings.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useGraphSettingsStore } from '@/stores/graph-settings-store' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { Slider } from "@/components/ui/slider" -import { Label } from '../ui/label' -import { memo, useCallback } from 'react' -import { Button } from '../ui/button' -import { type ForceGraphSetting } from '@/types' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../ui/accordion' - -const GraphSettings = () => { - const settings = useGraphSettingsStore(s => s.settings) - const settingsModalOpen = useGraphSettingsStore(s => s.settingsModalOpen) - const setSettingsModalOpen = useGraphSettingsStore(s => s.setSettingsModalOpen) - const updateSetting = useGraphSettingsStore(s => s.updateSetting) - const resetSettings = useGraphSettingsStore(s => s.resetSettings) - - // Categorize settings - const categories = { - 'Simulation Control': [ - 'd3AlphaDecay', - 'd3AlphaMin', - 'd3VelocityDecay', - 'cooldownTicks', - 'cooldownTime', - 'warmupTicks' - ], - 'Visual Appearance': [ - 'nodeSize', - 'linkWidth', - 'linkDirectionalArrowLength', - 'linkDirectionalArrowRelPos', - 'linkDirectionalParticleSpeed' - ], - 'Layout': [ - 'dagLevelDistance' - ] - } - - return ( - - - - Graph settings - - Update the settings of your graph. Changes apply in real-time. - - - - - {Object.entries(categories).map(([categoryName, settingKeys]) => ( - - - {categoryName} - - - {settingKeys.map((key) => { - const setting = settings[key]; - if (!setting) return null; - return ( - - ); - })} - - - ))} - - -
- -
-
-
- ) -} - -export default GraphSettings - -type SettingItemProps = { - setting: ForceGraphSetting - label: string - updateSetting: (key: string, value: number) => void -} -const SettingItem = memo(({ setting, label, updateSetting }: SettingItemProps) => { - - const handleUpdateSetting = useCallback((newValues: number[]) => { - updateSetting(label, newValues[0]) - }, [setting, updateSetting]) - - return ( -
- -

{setting.description}

- -
- ) -}) \ No newline at end of file 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 3c5a932..015fdbe 100644 --- a/flowsint-app/src/renderer/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/graph-viewer.tsx @@ -7,10 +7,10 @@ import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react' import ForceGraph2D from 'react-force-graph-2d'; 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'; +import MiniMap from './minimap'; function truncateText(text: string, limit: number = 16) { if (text.length <= limit) @@ -35,8 +35,40 @@ interface GraphViewerProps { onGraphRef?: (ref: any) => void; instanceId?: string; // Add instanceId prop for instance-specific actions allowLasso?: boolean + minimap?: boolean } +// Graph viewer specific colors +export const GRAPH_COLORS = { + // Link colors + LINK_DEFAULT: 'rgba(128, 128, 128, 0.6)', + LINK_HIGHLIGHTED: 'rgba(255, 115, 0, 0.68)', + LINK_DIMMED: 'rgba(133, 133, 133, 0.23)', + LINK_LABEL_HIGHLIGHTED: 'rgba(255, 115, 0, 0.9)', + LINK_LABEL_DEFAULT: 'rgba(180, 180, 180, 0.75)', + + // Node highlight colors + NODE_HIGHLIGHT_HOVER: 'rgba(255, 0, 0, 0.3)', + NODE_HIGHLIGHT_DEFAULT: 'rgba(255, 165, 0, 0.3)', + + LASSO_FILL: 'rgba(255, 115, 0, 0.07)', + LASSO_STROKE: 'rgba(255, 115, 0, 0.56)', + + // Text colors + TEXT_LIGHT: '#161616', + TEXT_DARK: '#FFFFFF', + + // Background colors + BACKGROUND_LIGHT: "#FFFFFF", + BACKGROUND_DARK: '#161616', + + // Transparent colors + TRANSPARENT: "#00000000", + + // Default node color + NODE_DEFAULT: '#0074D9', +} as const + const CONSTANTS = { NODE_COUNT_THRESHOLD: 500, NODE_DEFAULT_SIZE: 10, @@ -116,9 +148,14 @@ const GraphViewer: React.FC = ({ style, onGraphRef, instanceId, - allowLasso = false + allowLasso = false, + minimap = false }) => { - const [currentZoom, setCurrentZoom] = useState(1); + const [currentZoom, setCurrentZoom] = useState({ + k: 1, + x: 1, + y: 1 + }); const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); const isLassoActive = useGraphControls(s => s.isLassoActive) // Hover highlighting state @@ -136,8 +173,10 @@ const GraphViewer: React.FC = ({ // Store selectors const nodeColors = useNodesDisplaySettings(s => s.colors); const getSize = useNodesDisplaySettings(s => s.getSize); - const settings = useGraphSettingsStore(s => s.settings); const view = useGraphControls(s => s.view); + + // Get settings by categories to avoid re-renders + const forceSettings = useGraphSettingsStore(s => s.forceSettings); const setActions = useGraphControls(s => s.setActions); const currentNode = useGraphStore(s => s.currentNode) const selectedNodes = useGraphStore(s => s.selectedNodes) @@ -276,7 +315,7 @@ const GraphViewer: React.FC = ({ // Memoized rendering check const shouldUseSimpleRendering = useMemo(() => - nodes.length > CONSTANTS.NODE_COUNT_THRESHOLD || currentZoom < 1.5 + nodes.length > CONSTANTS.NODE_COUNT_THRESHOLD || currentZoom.k < 1.5 , [nodes.length, currentZoom]); // Memoized graph data transformation with proper memoization dependencies @@ -382,7 +421,7 @@ const GraphViewer: React.FC = ({ if (!showLabels) return new Set(); // Find the appropriate layer for current zoom - const currentLayer = CONSTANTS.LABEL_LAYERS.find(layer => currentZoom >= layer.minZoom); + const currentLayer = CONSTANTS.LABEL_LAYERS.find(layer => currentZoom.k >= layer.minZoom); if (!currentLayer) return new Set(); // Sort nodes by weight (number of connections) in descending order @@ -556,7 +595,7 @@ const GraphViewer: React.FC = ({ // Optimized node rendering with proper icon caching 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); + const size = Math.min((node.nodeSize + node.neighbors.length / 5), 20) * (forceSettings.nodeSize.value / 100 + .4); node.val = size / 5 const isHighlighted = highlightNodes.has(node.id) || isSelected(node.id); const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0; @@ -622,7 +661,7 @@ const GraphViewer: React.FC = ({ ctx.fillText(label, node.x, bgY + bgHeight / 2); } } - }, [shouldUseSimpleRendering, showLabels, showIcons, isCurrent, isSelected, settings.nodeSize.value, theme, highlightNodes, highlightLinks, hoverNode, getVisibleLabels]); + }, [shouldUseSimpleRendering, forceSettings, showLabels, showIcons, isCurrent, isSelected, theme, highlightNodes, highlightLinks, hoverNode, getVisibleLabels]); const renderLink = useCallback((link: any, ctx: CanvasRenderingContext2D) => { const { source: start, target: end } = link; @@ -633,18 +672,19 @@ const GraphViewer: React.FC = ({ let strokeStyle: string; let lineWidth: number; let fillStyle: string; + if (isHighlighted) { strokeStyle = GRAPH_COLORS.LINK_HIGHLIGHTED; fillStyle = GRAPH_COLORS.LINK_HIGHLIGHTED; - lineWidth = CONSTANTS.LINK_WIDTH * (settings.linkWidth.value / 3); + lineWidth = CONSTANTS.LINK_WIDTH * (forceSettings.linkWidth.value / 3); } else if (hasAnyHighlight) { strokeStyle = GRAPH_COLORS.LINK_DIMMED; fillStyle = GRAPH_COLORS.LINK_DIMMED; - lineWidth = CONSTANTS.LINK_WIDTH * (settings.linkWidth.value / 5); + lineWidth = CONSTANTS.LINK_WIDTH * (forceSettings.linkWidth.value / 5); } else { strokeStyle = GRAPH_COLORS.LINK_DEFAULT; fillStyle = GRAPH_COLORS.LINK_DEFAULT; - lineWidth = CONSTANTS.LINK_WIDTH * (settings.linkWidth.value / 5); + lineWidth = CONSTANTS.LINK_WIDTH * (forceSettings.linkWidth.value / 5); } // Draw connection line ctx.beginPath(); @@ -654,9 +694,9 @@ const GraphViewer: React.FC = ({ ctx.lineWidth = lineWidth; ctx.stroke(); // Draw directional arrow - const arrowLength = settings.linkDirectionalArrowLength?.value; + const arrowLength = forceSettings.linkDirectionalArrowLength.value; if (arrowLength && arrowLength > 0) { - const arrowRelPos = settings.linkDirectionalArrowRelPos?.value || 1; + const arrowRelPos = forceSettings.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; @@ -667,7 +707,7 @@ const GraphViewer: React.FC = ({ 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); + const targetNodeSize = (end.nodeSize || CONSTANTS.NODE_DEFAULT_SIZE) * (forceSettings.nodeSize.value / 100 + 0.4); // Calculate offset to place arrow at node edge const offset = targetNodeSize / distance; arrowX = end.x - dx * offset; @@ -727,21 +767,28 @@ const GraphViewer: React.FC = ({ ctx.fillText(link.label, 0, 0); ctx.restore(); } - }, [shouldUseSimpleRendering, settings.linkWidth.value, settings.linkDirectionalArrowLength?.value, settings.linkDirectionalArrowRelPos?.value, settings.nodeSize.value, theme, highlightLinks, highlightNodes]); + }, [shouldUseSimpleRendering, forceSettings, theme, highlightLinks, highlightNodes]); // Restart simulation when settings change (debounced) useEffect(() => { let settingsTimeout: number | undefined; - if (graphRef.current && isGraphReadyRef.current) { - if (settingsTimeout) clearTimeout(settingsTimeout); - settingsTimeout = setTimeout(() => { - graphRef.current?.d3ReheatSimulation(); - }, 100) as any; // Debounce settings changes - } + + const restartSimulation = () => { + if (graphRef.current && isGraphReadyRef.current) { + if (settingsTimeout) clearTimeout(settingsTimeout); + settingsTimeout = setTimeout(() => { + graphRef.current?.d3ReheatSimulation(); + }, 100) as any; // Debounce settings changes + } + }; + + // Restart simulation when force settings change + restartSimulation(); + return () => { if (settingsTimeout) clearTimeout(settingsTimeout); }; - }, [settings]); + }, [forceSettings]); // Clear highlights when graph data changes useEffect(() => { @@ -851,15 +898,16 @@ const GraphViewer: React.FC = ({ node.fx = node.x; node.fy = node.y; })} - cooldownTicks={view === "hierarchy" ? 0 : settings.cooldownTicks?.value} - cooldownTime={settings.cooldownTime?.value} - d3AlphaDecay={settings.d3AlphaDecay?.value} - d3AlphaMin={settings.d3AlphaMin?.value} - d3VelocityDecay={settings.d3VelocityDecay?.value} - warmupTicks={settings.warmupTicks?.value} - dagLevelDistance={settings.dagLevelDistance?.value} + cooldownTicks={view === "hierarchy" ? 0 : forceSettings.cooldownTicks.value} + cooldownTime={forceSettings.cooldownTime.value} + d3AlphaDecay={forceSettings.d3AlphaDecay.value} + d3AlphaMin={forceSettings.d3AlphaMin.value} + d3VelocityDecay={forceSettings.d3VelocityDecay.value} + warmupTicks={forceSettings.warmupTicks.value} + dagLevelDistance={forceSettings.dagLevelDistance.value} backgroundColor={backgroundColor} - onZoom={(zoom) => setCurrentZoom(zoom.k)} + onZoom={(zoom) => setCurrentZoom(zoom)} + onZoomEnd={(zoom) => setCurrentZoom(zoom)} linkCanvasObject={renderLink} enableNodeDrag={!shouldUseSimpleRendering} autoPauseRedraw={true} @@ -868,6 +916,11 @@ const GraphViewer: React.FC = ({ /> {allowLasso && isLassoActive && } + {minimap && graphData.nodes && + } ); }; diff --git a/flowsint-app/src/renderer/src/components/graphs/index.tsx b/flowsint-app/src/renderer/src/components/graphs/index.tsx index 3400852..b28490d 100644 --- a/flowsint-app/src/renderer/src/components/graphs/index.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/index.tsx @@ -15,16 +15,15 @@ import { useActionItems } from '@/hooks/use-action-items' import { toast } from 'sonner' import MapPanel from '../map/map-panel' import NewActions from './add-item-dialog' -import GraphSettings from './graph-settings' import GraphMain from './graph-main' -import GlobalSettings, { KeyboardShortcuts } from './global-settings' +import Settings, { KeyboardShortcuts } from './settings' import { type GraphNode, type GraphEdge } from '@/types' +import { MergeDialog } from './merge-nodes' +import { useGraphSettingsStore } from '@/stores/graph-settings-store' const RelationshipsTable = lazy(() => import('@/components/table/relationships-view')) const Graph = lazy(() => import('./graph')) // const Wall = lazy(() => import('./wall/wall')) -const NODE_COUNT_THRESHOLD = 1500; - // Separate component for the drag overlay const DragOverlay = memo(({ isDragging }: { isDragging: boolean }) => (
{ const handleOpenFormModal = useGraphStore(s => s.handleOpenFormModal) const nodes = useGraphStore(s => s.nodes) const view = useGraphControls((s) => s.view) + const settings = useGraphSettingsStore(s => s.settings) const updateGraphData = useGraphStore(s => s.updateGraphData) const setFilters = useGraphStore(s => s.setFilters) const filters = useGraphStore(s => s.filters) const { actionItems, isLoading: isLoadingActionItems } = useActionItems() + const NODE_COUNT_THRESHOLD = settings.general.graphViewerThreshold.value + const { sketch } = useLoaderData({ from: '/_auth/dashboard/investigations/$investigationId/$type/$id', }) @@ -162,9 +164,9 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => {
+ - - + ) diff --git a/flowsint-app/src/renderer/src/components/graphs/lasso.tsx b/flowsint-app/src/renderer/src/components/graphs/lasso.tsx index 80527c2..ce4e4f4 100644 --- a/flowsint-app/src/renderer/src/components/graphs/lasso.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/lasso.tsx @@ -1,6 +1,7 @@ import { memo, useRef, type PointerEvent } from 'react'; import { useGraphStore } from '@/stores/graph-store'; import { GraphNode } from '@/types'; +import { GRAPH_COLORS } from './graph-viewer'; type NodePoints = ([number, number] | [number, number, number])[]; type NodePointObject = Record; @@ -46,7 +47,6 @@ export function Lasso({ partial, width, height, graph2ScreenCoords, nodes }: { p 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); @@ -64,8 +64,8 @@ export function Lasso({ partial, width, height, graph2ScreenCoords, nodes }: { p ctxRef.current = ctx; ctx.lineWidth = 1; - ctx.fillStyle = 'rgba(0, 89, 220, 0.08)'; - ctx.strokeStyle = 'rgba(0, 89, 220, 0.8)'; + ctx.fillStyle = `${GRAPH_COLORS.LASSO_FILL}`; + ctx.strokeStyle = GRAPH_COLORS.LASSO_STROKE; } function handlePointerMove(e: PointerEvent) { 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 42a45b6..abc7905 100644 --- a/flowsint-app/src/renderer/src/components/graphs/launch-transform.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/launch-transform.tsx @@ -18,7 +18,7 @@ import { useLaunchTransform } from "@/hooks/use-launch-transform" import { useLaunchFlow } from "@/hooks/use-launch-flow" import { formatDistanceToNow } from "date-fns" import { useQuery } from "@tanstack/react-query" -import { transformService } from "@/api/transfrom-service" +import { transformService } from "@/api/transform-service" import { flowService } from '@/api/flow-service'; import { Link, useParams } from "@tanstack/react-router" import { capitalizeFirstLetter } from "@/lib/utils" diff --git a/flowsint-app/src/renderer/src/components/graphs/merge-nodes.tsx b/flowsint-app/src/renderer/src/components/graphs/merge-nodes.tsx new file mode 100644 index 0000000..da7cb3e --- /dev/null +++ b/flowsint-app/src/renderer/src/components/graphs/merge-nodes.tsx @@ -0,0 +1,176 @@ +import { useEffect, useState } from "react" +import { useGraphStore } from "@/stores/graph-store" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Info, Merge, MinusCircleIcon, PlusCircleIcon, X } from "lucide-react" +import { Label } from "@/components/ui/label" +import { toast } from "sonner" +import { GraphNode } from "@/types" +import { NodeDisplayCard } from "./create-relation" +import { RadioGroup } from "@/components/ui/radio-group" +import { Alert, AlertDescription, AlertTitle } from "../ui/alert" +import { cn, deepObjectDiff } from "@/lib/utils" +import { sketchService } from "@/api/sketch-service" +import { useParams } from "@tanstack/react-router" + +export function MergeDialog() { + const { id: sketchId } = useParams({ strict: false }) + const selectedNodes = useGraphStore((state) => state.selectedNodes || []) + const openMergeDialog = useGraphStore((state) => state.openMergeDialog) + const setOpenMergeDialog = useGraphStore((state) => state.setOpenMergeDialog) + const [priorityIndex, setPriorityIndex] = useState(0) + const [preview, setPreview] = useState(null) + const [old, setOld] = useState(null) + + useEffect(() => { + if (selectedNodes.length === 0) return + const priorityEntity = selectedNodes[priorityIndex] + if (!priorityEntity) return + const otherEntitiesToMerge = selectedNodes.filter((node) => node?.id != priorityEntity?.id) + const old = { + ...otherEntitiesToMerge.reduce((acc, entity) => ({ + ...acc, + ...entity, + }), {} as GraphNode) + } + const merge = { + ...old, + ...priorityEntity, + } + setOld(old) + setPreview(merge) + }, [selectedNodes, priorityIndex, setPreview]) + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault() + if (!preview) + return toast.error("No preview available.") + if (!selectedNodes.every((n) => n.data.type === selectedNodes[0].data.type)) + return toast.error("Cannot merge entities of different types.") + const entitiesToMerge = selectedNodes.map((node) => node.id) + const body = JSON.stringify({ oldNodes: entitiesToMerge, newNode: preview }) + return toast.promise( + (async () => { + await sketchService.mergeNodes(sketchId as string, body) + setOpenMergeDialog(false) + })(), + { + loading: 'Merging entities...', + success: 'Entities successfully merged.', + error: (error) => { + console.log(error) + return 'Unexpected error during merging.' + } + } + ) + } + + return ( + + + + + + Merging {selectedNodes.length} entities + + + + {/* Selected Nodes Display */} +
+ + + Heads up! + + When merging, you can choose which entity is prioritary, simply click it in the list below. + Also keep in mind: +
+
    +
  • All attributes will be merged, and the matching ones values will be based on the prioritary entity
  • +
  • Only one node will be kept, and all relationships will be merged
  • +
+
+
+
+ + setPriorityIndex(parseInt(value, 10))} + > + {selectedNodes.map((node, index) => ( + + ) + )} + +
+ {preview && old &&
+

Merged attributes

+
+ +
+
} + + + + + +
+
+ ) +} + +interface KeyValueDisplayDiffProps { + oldRecord: Record + newRecord: Record +} +function KeyValueDisplayDiff({ oldRecord, newRecord }: KeyValueDisplayDiffProps) { + const diffObject = deepObjectDiff(oldRecord, newRecord) + return ( +
+ {diffObject && Object.entries(diffObject) + .filter(([key]) => !["sketch_id", "caption", "size", "color", "description"].includes(key)) + .map(([key, value], index) => { + const isNewKey = value.new + const hasChanged = !value.identical + const isOldKey = value.removed + return ( +
+
+ {isNewKey && } + {isOldKey && } + {key} +
+
+ {!hasChanged ?
{JSON.stringify(value.value ?? "")}
: +
+ {JSON.stringify(value.oldValue)}{JSON.stringify(value.newValue ?? "")}
} +
+
+ ) + })} +
+ ) +} diff --git a/flowsint-app/src/renderer/src/components/graphs/minimap.tsx b/flowsint-app/src/renderer/src/components/graphs/minimap.tsx new file mode 100644 index 0000000..a1f35c9 --- /dev/null +++ b/flowsint-app/src/renderer/src/components/graphs/minimap.tsx @@ -0,0 +1,147 @@ +import { GraphNode } from '@/types'; +import { useMemo } from 'react'; +import { GRAPH_COLORS } from './graph-viewer'; + +interface zoomTransform { + x: number, + y: number, + k: number, +} +interface MinimapProps { + nodes: GraphNode[] + zoomTransform: zoomTransform + width?: number, + height?: number + canvasWidth: number + canvasHeight: number +} +const Minimap = ({ + nodes, + zoomTransform, + width = 160, + height = 120, + canvasWidth = 800, + canvasHeight = 600, +}: MinimapProps) => { + // Calculate the visible viewport in world coordinates + const invScale = 1 / zoomTransform.k; + + // The viewport center in world coordinates + // zoomTransform.x and zoomTransform.y are screen offsets, so we need to convert them + const viewportCenterX = zoomTransform.x * invScale; + const viewportCenterY = zoomTransform.y * invScale; + + // Viewport dimensions in world coordinates + const viewportWidth = canvasWidth * invScale; + const viewportHeight = canvasHeight * invScale; + + // Viewport bounds in world coordinates + const viewportX1 = viewportCenterX - viewportWidth / 2; + const viewportY1 = viewportCenterY - viewportHeight / 2; + const viewportX2 = viewportCenterX + viewportWidth / 2; + const viewportY2 = viewportCenterY + viewportHeight / 2; + + // Calculate global bounding box including nodes and viewport + const [minX, minY, maxX, maxY] = useMemo(() => { + const validNodes = nodes.filter(n => + typeof n.x === 'number' && + typeof n.y === 'number' + ); + + if (validNodes.length === 0) { + // If no nodes, just show the viewport + return [viewportX1, viewportY1, viewportX2, viewportY2]; + } + + const nodeXs = validNodes.map(n => n.x); + const nodeYs = validNodes.map(n => n.y); + + const allX = [...nodeXs, viewportX1, viewportX2]; + const allY = [...nodeYs, viewportY1, viewportY2]; + + return [ + Math.min(...allX.filter((x): x is number => x !== undefined)), + Math.min(...allY.filter((y): y is number => y !== undefined)), + Math.max(...allX.filter((x): x is number => x !== undefined)), + Math.max(...allY.filter((y): y is number => y !== undefined)), + ]; + }, [nodes, viewportX1, viewportY1, viewportX2, viewportY2]); + + const worldWidth = maxX - minX || 1; + const worldHeight = maxY - minY || 1; + + // Scale to fit everything in the minimap + const scaleX = width / worldWidth; + const scaleY = height / worldHeight; + const scale = Math.min(scaleX, scaleY); + + // Center the minimap content + const offsetX = (width - worldWidth * scale) / 2; + const offsetY = (height - worldHeight * scale) / 2; + + // Project world coordinates to minimap coordinates + const project = (x, y) => [ + (x - minX) * scale + offsetX, + (y - minY) * scale + offsetY + ]; + + // Project viewport bounds to minimap coordinates + const [vx1, vy1] = project(viewportX1, viewportY1); + const [vx2, vy2] = project(viewportX2, viewportY2); + + // Calculate viewBox dimensions with padding + const viewBoxWidth = Math.abs(vx2 - vx1) + 6; + const viewBoxHeight = Math.abs(vy2 - vy1) + 6; + + // Position the viewBox to show the current viewport + const viewBoxX = Math.min(vx1, vx2) - 3; + const viewBoxY = Math.min(vy1, vy2) - 3; + + // Fixed node radius for consistent sizing + const nodeRadius = 2; + + return ( +
+ + {nodes + .filter((n: GraphNode) => + typeof n.x === 'number' && typeof n.y === 'number' + ) + .map((n: GraphNode, i: number) => { + const [cx, cy] = project(n.x, n.y); + return ( + + ); + })} + + {/* Viewport rectangle - shows current visible area */} + + +
+ ); +}; + +export default Minimap; diff --git a/flowsint-app/src/renderer/src/components/graphs/new-sketch.tsx b/flowsint-app/src/renderer/src/components/graphs/new-sketch.tsx index ac7a177..7840ec7 100644 --- a/flowsint-app/src/renderer/src/components/graphs/new-sketch.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/new-sketch.tsx @@ -1,6 +1,11 @@ -import React, { useState } from "react" +import { useState, type ReactNode } from "react" import { useForm } from "react-hook-form" +import { useRouter, useParams } from "@tanstack/react-router" +import { toast } from "sonner" +import { sketchService } from "@/api/sketch-service" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { queryKeys } from "@/api/query-keys" import { Button } from "@/components/ui/button" import { Dialog, @@ -11,33 +16,23 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { toast } from "sonner" -import { useParams, useRouter } from "@tanstack/react-router" -import { sketchService } from "@/api/sketch-service" -type FormValues = { +interface FormValues { title: string description?: string } -export default function NewSketch({ - children, - noDropDown = false, -}: { - children: React.ReactNode - noDropDown?: boolean -}) { +interface NewSketchProps { + children: ReactNode +} + +export default function NewSketch({ children }: NewSketchProps) { const [open, setOpen] = useState(false) const router = useRouter() const { investigationId } = useParams({ strict: false }) + const queryClient = useQueryClient() const { register, @@ -46,6 +41,36 @@ export default function NewSketch({ reset, } = useForm() + // Create sketch mutation + const createSketchMutation = useMutation({ + mutationFn: sketchService.create, + onSuccess: (result) => { + if (result.id) { + setOpen(false) + toast.success("New sketch created.") + router.navigate({ + to: `/dashboard/investigations/${investigationId}/graph/${result.id}`, + }) + reset() + // Invalidate sketches list and investigation sketches + queryClient.invalidateQueries({ + queryKey: queryKeys.sketches.list + }) + if (investigationId) { + queryClient.invalidateQueries({ + queryKey: queryKeys.investigations.sketches(investigationId) + }) + } + } else { + toast.error(result.error || "Failed to create sketch.") + } + }, + onError: (error) => { + toast.error("Unexpected error occurred.") + console.error(error) + } + }) + async function onSubmit(data: FormValues) { if (!investigationId) { toast.error("A sketch must be related to an investigation.") @@ -57,21 +82,9 @@ export default function NewSketch({ ...data, investigation_id: investigationId, } - const result = await sketchService.create(JSON.stringify(payload)) - - if (result.id) { - setOpen(false) - toast.success("New sketch created.") - router.navigate({ - to: `/dashboard/investigations/${investigationId}/graph/${result.id}`, - }) - reset() - } else { - toast.error(result.error || "Failed to create sketch.") - } + await createSketchMutation.mutateAsync(JSON.stringify(payload)) } catch (error) { - toast.error("Unexpected error occurred.") - console.error(error) + console.error('Error creating sketch:', error) } } @@ -121,48 +134,19 @@ export default function NewSketch({ ) - if (noDropDown) { - return ( - - -
{children}
-
- - - New sketch - Create a new blank sketch. - - {formContent} - -
- ) - } - return ( - <> - -
{children}
- - setOpen(true)}> - New sketch - {/* ⌘ E */} - - - New wall - {/* ⌘ E */} - - -
- - - - - New sketch - Create a new blank sketch. - - {formContent} - - - + + +
{children}
+
+ + + New sketch + Create a new blank sketch. + + {formContent} + +
) } + diff --git a/flowsint-app/src/renderer/src/components/graphs/node-actions.tsx b/flowsint-app/src/renderer/src/components/graphs/node-actions.tsx index a17fb3e..299c191 100644 --- a/flowsint-app/src/renderer/src/components/graphs/node-actions.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/node-actions.tsx @@ -1,7 +1,7 @@ import React, { memo, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Pencil, Trash2, Sparkles, Plus } from 'lucide-react'; -import { GraphNode, useGraphStore } from '@/stores/graph-store'; +import { useGraphStore } from '@/stores/graph-store'; import { useParams } from '@tanstack/react-router'; import { useConfirm } from '@/components/use-confirm-dialog'; import { toast } from 'sonner'; @@ -13,6 +13,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { useLayoutStore } from '@/stores/layout-store'; +import { GraphNode } from '@/types'; const NodeActions = memo(({ node, setMenu }: { node: GraphNode, setMenu?: (menu: any | null) => void }) => { diff --git a/flowsint-app/src/renderer/src/components/graphs/selected-items-panel.tsx b/flowsint-app/src/renderer/src/components/graphs/selected-items-panel.tsx index a68a57a..e8fdabb 100644 --- a/flowsint-app/src/renderer/src/components/graphs/selected-items-panel.tsx +++ b/flowsint-app/src/renderer/src/components/graphs/selected-items-panel.tsx @@ -2,7 +2,6 @@ import React, { memo, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Trash2, Sparkles, XIcon, Rocket } from 'lucide-react'; import { useGraphStore } from '@/stores/graph-store'; -import { useParams } from '@tanstack/react-router'; import { useConfirm } from '@/components/use-confirm-dialog'; import { toast } from 'sonner'; import { sketchService } from '@/api/sketch-service'; @@ -18,6 +17,7 @@ import { useNodesDisplaySettings } from '@/stores/node-display-settings'; import { Badge } from '../ui/badge'; import LaunchFlow from './launch-transform'; import { TypeBadge } from '../type-badge'; +import { useParams } from '@tanstack/react-router'; const SelectedItemsPanel = () => { const selectedNodes = useGraphStore(s => s.selectedNodes) diff --git a/flowsint-app/src/renderer/src/components/graphs/settings.tsx b/flowsint-app/src/renderer/src/components/graphs/settings.tsx new file mode 100644 index 0000000..528d77a --- /dev/null +++ b/flowsint-app/src/renderer/src/components/graphs/settings.tsx @@ -0,0 +1,702 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { Switch } from "@/components/ui/switch" +import { Slider } from "@/components/ui/slider" +import { toast } from "sonner" + +import { useGraphSettingsStore } from "@/stores/graph-settings-store" +import { isMacOS } from "@/components/analyses/editor/utils" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Sketch } from "@/types" +import { useParams } from "@tanstack/react-router" +import { sketchService } from "@/api/sketch-service" +import { useState, useEffect } from "react" +import { queryKeys } from "@/api/query-keys" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs" + +// SettingItem Components +interface SettingItemProps { + label: string + description?: string + children: React.ReactNode + inline?: boolean +} + +function SettingItem({ label, description, children, inline = false }: SettingItemProps) { + if (inline) { + return ( +
+
+ + {description && ( +

{description}

+ )} +
+
+ {children} +
+
+ ) + } + + return ( +
+
+ + {description && ( +

{description}

+ )} +
+ {children} +
+ ) +} + +// Dynamic Setting Components +interface DynamicSettingProps { + categoryId: string + settingKey: string + setting: any + onValueChange: (value: any) => void +} + +function DynamicSetting({ categoryId, settingKey, setting, onValueChange }: DynamicSettingProps) { + + // For force settings, always use slider regardless of type + const shouldUseSlider = categoryId === "graph" || setting.type === "slider" + + if (shouldUseSlider && (setting.type === "number" || setting.type === "slider")) { + return ( + +
+ onValueChange(value)} + min={setting.min || 0} + max={setting.max || 100} + step={setting.step || 1} + className="flex-1" + /> + onValueChange(Number(e.target.value))} + min={setting.min} + max={setting.max} + step={setting.step} + className="h-9 w-20" + /> +
+
+ ) + } + + switch (setting.type) { + case "boolean": + return ( + +
+ + + {setting.value ? "Enabled" : "Disabled"} + +
+
+ ) + + case "select": + return ( + + + + ) + + case "text": + return ( + + onValueChange(e.target.value)} + placeholder="Enter value" + className="h-10" + /> + + ) + + case "textarea": + return ( + +