From 2cac709bbcc08ac87eeda92c3daaa06605cda952 Mon Sep 17 00:00:00 2001 From: dextmorgn Date: Thu, 27 Nov 2025 15:39:34 +0100 Subject: [PATCH] feat(app): refetch on new nodes --- flowsint-app/src/components/graphs/index.tsx | 6 +- flowsint-app/src/hooks/use-events.ts | 11 +-- flowsint-app/src/hooks/use-graph-refresh.ts | 72 +++++++++++++++++++ ...estigations.$investigationId.$type.$id.tsx | 14 +++- .../src/stores/graph-controls-store.ts | 4 +- 5 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 flowsint-app/src/hooks/use-graph-refresh.ts diff --git a/flowsint-app/src/components/graphs/index.tsx b/flowsint-app/src/components/graphs/index.tsx index 72d4e3a..3ad2c81 100644 --- a/flowsint-app/src/components/graphs/index.tsx +++ b/flowsint-app/src/components/graphs/index.tsx @@ -19,6 +19,7 @@ import GraphMain from './graph-main' import Settings, { KeyboardShortcuts } from './settings' import { type GraphNode, type GraphEdge } from '@/types' import { MergeDialog } from './merge-nodes' +import { useGraphRefresh } from '@/hooks/use-graph-refresh' const RelationshipsTable = lazy(() => import('@/components/table/relationships-view')) // Separate component for the drag overlay @@ -50,11 +51,14 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => { const filters = useGraphStore((s) => s.filters) const { actionItems, isLoading: isLoadingActionItems } = useActionItems() - const { sketch } = useLoaderData({ + const { params, sketch } = useLoaderData({ from: '/_auth/dashboard/investigations/$investigationId/$type/$id' }) const [isDraggingOver, setIsDraggingOver] = useState(false) + // Dedicated hook for graph refresh on transform completion + useGraphRefresh(params.id) + useEffect(() => { if (graphData?.nds && graphData?.rls) { updateGraphData(graphData.nds, graphData.rls) diff --git a/flowsint-app/src/hooks/use-events.ts b/flowsint-app/src/hooks/use-events.ts index d2ade09..a471765 100644 --- a/flowsint-app/src/hooks/use-events.ts +++ b/flowsint-app/src/hooks/use-events.ts @@ -1,8 +1,6 @@ import { useEffect, useState, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { logService } from '@/api/log-service' -import { EventLevel } from '@/types' -import { useGraphControls } from '@/stores/graph-controls-store' import { queryKeys } from '@/api/query-keys' const API_URL = import.meta.env.VITE_API_URL @@ -10,8 +8,6 @@ const API_URL = import.meta.env.VITE_API_URL export function useEvents(sketch_id: string | undefined) { const [liveLogs, setLiveLogs] = useState([]) - const refetchGraph = useGraphControls((s) => s.refetchGraph) - const setShouldRegenerateLayoutOnNextRefetch = useGraphControls((s) => s.setShouldRegenerateLayoutOnNextRefetch) const { data: previousLogs = [], refetch } = useQuery({ queryKey: queryKeys.logs.bySketch(sketch_id as string), @@ -40,11 +36,6 @@ export function useEvents(sketch_id: string | undefined) { try { const raw = JSON.parse(e.data) as any const event = JSON.parse(raw.data) as Event - if (event.type === EventLevel.COMPLETED) { - // Set flag to regenerate layout after refetch completes - setShouldRegenerateLayoutOnNextRefetch(true) - refetchGraph() - } setLiveLogs((prev) => [...prev.slice(-99), event]) } catch (error) { console.error('[useSketchEvents] Failed to parse SSE event:', error) @@ -59,7 +50,7 @@ export function useEvents(sketch_id: string | undefined) { return () => { eventSource.close() } - }, [sketch_id, refetchGraph]) + }, [sketch_id]) const logs = useMemo( () => [...previousLogs, ...liveLogs].slice(-100), diff --git a/flowsint-app/src/hooks/use-graph-refresh.ts b/flowsint-app/src/hooks/use-graph-refresh.ts new file mode 100644 index 0000000..37ebfe5 --- /dev/null +++ b/flowsint-app/src/hooks/use-graph-refresh.ts @@ -0,0 +1,72 @@ +import { useEffect } from 'react' +import { useGraphControls } from '@/stores/graph-controls-store' +import { EventLevel } from '@/types' + +const API_URL = import.meta.env.VITE_API_URL + +/** + * Dedicated hook for graph refresh on transform completion. + * + * This hook listens ONLY to COMPLETED events via SSE and triggers + * a graph refetch followed by layout regeneration. It's completely + * separate from the logging system (use-events.ts). + * + * Architecture: + * - Transform completes → Logger.completed() → SSE event + * - This hook receives COMPLETED event + * - Calls refetchGraph() with callback + * - Callback triggers regenerateLayout() with fresh data + */ +export function useGraphRefresh(sketch_id: string | undefined) { + const refetchGraph = useGraphControls((s) => s.refetchGraph) + const regenerateLayout = useGraphControls((s) => s.regenerateLayout) + const currentLayoutType = useGraphControls((s) => s.currentLayoutType) + + useEffect(() => { + if (!sketch_id) return + + console.log('[useGraphRefresh] Connecting to SSE for sketch:', sketch_id) + const eventSource = new EventSource( + `${API_URL}/api/events/sketch/${sketch_id}/stream` + ) + + eventSource.onopen = () => { + console.log('[useGraphRefresh] SSE connection opened') + } + + eventSource.onmessage = (e) => { + try { + const raw = JSON.parse(e.data) as any + const event = JSON.parse(raw.data) as any + console.log('[useGraphRefresh] Received event:', event.type, event) + + // Only handle COMPLETED events + if (event.type === EventLevel.COMPLETED) { + console.log('[useGraphRefresh] COMPLETED event detected, triggering refetch') + console.log('[useGraphRefresh] refetchGraph function:', refetchGraph) + console.log('[useGraphRefresh] currentLayoutType:', currentLayoutType) + + // Refetch graph data, then regenerate layout with fresh data + refetchGraph(() => { + console.log('[useGraphRefresh] Refetch callback executing, regenerating layout') + if (currentLayoutType) { + regenerateLayout(currentLayoutType) + } + }) + } + } catch (error) { + console.error('[useGraphRefresh] Failed to parse SSE event:', error) + } + } + + eventSource.onerror = (error) => { + console.error('[useGraphRefresh] EventSource error:', error) + eventSource.close() + } + + return () => { + console.log('[useGraphRefresh] Disconnecting SSE') + eventSource.close() + } + }, [sketch_id, refetchGraph, regenerateLayout, currentLayoutType]) +} diff --git a/flowsint-app/src/routes/_auth.dashboard.investigations.$investigationId.$type.$id.tsx b/flowsint-app/src/routes/_auth.dashboard.investigations.$investigationId.$type.$id.tsx index fa6b177..a522ac9 100644 --- a/flowsint-app/src/routes/_auth.dashboard.investigations.$investigationId.$type.$id.tsx +++ b/flowsint-app/src/routes/_auth.dashboard.investigations.$investigationId.$type.$id.tsx @@ -45,7 +45,19 @@ const GraphPageContent = () => { }, [id, reset]) useEffect(() => { - setActions({ refetchGraph: refetch }) + const refetchWithCallback = async (onSuccess?: () => void) => { + console.log('[Route] refetchWithCallback called with callback:', !!onSuccess) + await refetch() + console.log('[Route] refetch completed') + // Execute callback after refetch completes and data is updated + if (onSuccess) { + console.log('[Route] executing callback after refetch') + // Small delay to ensure React Query has updated the data in the store + setTimeout(onSuccess, 0) + } + } + console.log('[Route] Setting refetchGraph in store') + setActions({ refetchGraph: refetchWithCallback }) }, [refetch, setActions, id]) if (type === 'graph') { diff --git a/flowsint-app/src/stores/graph-controls-store.ts b/flowsint-app/src/stores/graph-controls-store.ts index 8f6ca95..d9a0265 100644 --- a/flowsint-app/src/stores/graph-controls-store.ts +++ b/flowsint-app/src/stores/graph-controls-store.ts @@ -16,7 +16,7 @@ type GraphControlsStore = { zoomOut: () => void onLayout: (layout: any) => void setActions: (actions: Partial) => void - refetchGraph: () => void + refetchGraph: (onSuccess?: () => void) => void regenerateLayout: (layoutType: LayoutType) => void setCurrentLayoutType: (layoutType: LayoutType) => void setShouldRegenerateLayoutOnNextRefetch: (should: boolean) => void @@ -38,7 +38,7 @@ export const useGraphControls = create()( zoomOut: () => { }, onLayout: () => { }, setActions: (actions) => set(actions), - refetchGraph: () => { }, + refetchGraph: (onSuccess) => { }, regenerateLayout: () => { }, setCurrentLayoutType: (layoutType) => set({ currentLayoutType: layoutType }), setShouldRegenerateLayoutOnNextRefetch: (should) => set({ shouldRegenerateLayoutOnNextRefetch: should }),