From a6dcd9bea34935542dabe81edb7565375338386f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Wed, 15 Jul 2026 23:58:25 +0200 Subject: [PATCH] :sparkles: Add query-result subgraph, ws auto-reconnect, adaptive animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cypher result pane now offers 'Show result in graph view': any UUID found in any result cell selects the matching nodes in the cached export and the view renders the induced subgraph (edges kept when both endpoints match); 'Show full graph' resets. No graph reconstruction from the query result is needed. The notifications websocket reconnects automatically (3 s retry) and resubscribes + refetches on reopen, so backend restarts no longer permanently kill the live feed; a lost session now reports 'no graph session (backend restarted?) - reload a file' instead of a bare 404. Animation is size-adaptive: graphs (or filtered subgraphs) up to 100 nodes render animated for didactics, larger ones stay animation-free; crossing the threshold recreates the instance like a layout switch. Signed-off-by: Álvaro Tejero Cantero --- .../app/templates/graph-console.tmpl | 107 ++++++++++++++++-- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl index 907383e2dc..dd0dfd3316 100644 --- a/backend/resources/app/templates/graph-console.tmpl +++ b/backend/resources/app/templates/graph-console.tmpl @@ -172,6 +172,7 @@ Graph Console style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;">
+ {% endif %} @@ -306,6 +307,10 @@ Graph Console // Above this many nodes the view is not rendered automatically; the // "Render anyway" button forces it (~1.5 s per 2k nodes, measured). const RENDER_GUARD_NODES = 4000; + // G6's entrance/update animation is nice didactics on small graphs but + // the performance killer at scale (>2 min at 1700 nodes vs ~1.5 s off); + // keep it only below this node count. + const ANIMATE_MAX_NODES = 100; const LAYOUT_XS = 40; // horizontal leaf slot spacing const LAYOUT_YS = 70; // vertical rank (depth) spacing @@ -338,9 +343,11 @@ Graph Console const graphViewStatus = document.getElementById("graph-view-status"); let g6graph = null; let g6graphLayout = null; + let g6graphAnimated = null; let refetchTimer = null; let lastGraphData = null; let renderForced = false; + let graphFilterIds = null; function currentLayoutName() { const stored = localStorage.getItem("graph-layout"); @@ -446,9 +453,10 @@ Graph Console function renderGraph(g6data) { const layoutName = currentLayoutName(); - if (g6graph && g6graphLayout !== layoutName) { - // Layout switch: recreate the graph (cheap with animation off); - // preset-tree <-> real-layout cannot be swapped on a live instance. + const animate = g6data.nodes.length <= ANIMATE_MAX_NODES; + if (g6graph && (g6graphLayout !== layoutName || g6graphAnimated !== animate)) { + // Layout or animation-mode switch: recreate the graph (cheap); + // neither is swappable on a live instance. try { g6graph.destroy(); } catch (_err) {} g6graph = null; } @@ -459,15 +467,14 @@ Graph Console }); } g6graphLayout = layoutName; + g6graphAnimated = animate; const layoutCfg = LAYOUTS[layoutName]; const opts = { container: "graph-canvas", data: g6data, autoFit: "view", autoResize: true, - // Entrance/update animation is the performance killer: 1700 nodes - // took >2 min animated vs ~1.5 s without (measured, headless). - animation: false, + animation: animate, padding: 20, node: { type: function (d) { return nodeStyle(d.data.table).glyph; }, @@ -525,18 +532,53 @@ Graph Console + (data.truncated ? " — WARNING: truncated at row cap, view is partial" : ""); } + // Query filter: induced subgraph of the already-exported graph, selected + // by node ids found in the last Cypher result. No reconstruction from the + // query result is needed — ids are enough to slice the cached export. + function filteredGraphData() { + if (!graphFilterIds) return lastGraphData; + const keep = {}; + const nodes = lastGraphData.nodes.filter(function (n) { + if (graphFilterIds.has(n.id)) { keep[n.id] = true; return true; } + return false; + }); + const edges = lastGraphData.edges.filter(function (e) { + return keep[e.source] && keep[e.target]; + }); + return { nodes: nodes, edges: edges, + revn: lastGraphData.revn, truncated: lastGraphData.truncated }; + } + + function applyQueryFilter(ids) { + if (!lastGraphData) return; + const present = new Set(); + lastGraphData.nodes.forEach(function (n) { if (ids.has(n.id)) present.add(n.id); }); + if (!present.size) { + graphViewStatus.textContent = "query result matches no nodes in the loaded graph"; + return; + } + graphFilterIds = present; + renderCurrent(); + } + function renderCurrent() { if (!lastGraphData) return; - const data = lastGraphData; + const data = filteredGraphData(); const anywayBtn = document.getElementById("graph-render-anyway"); + const filterBtn = document.getElementById("graph-filter-reset"); + if (filterBtn) filterBtn.style.display = graphFilterIds ? "inline" : "none"; + const filterNote = graphFilterIds + ? " — query filter: " + data.nodes.length + " of " + + lastGraphData.nodes.length + " nodes" + : ""; if (data.nodes.length > RENDER_GUARD_NODES && !renderForced) { graphViewStatus.textContent = - graphStatusText(data) + " — too large to render automatically"; + graphStatusText(data) + filterNote + " — too large to render automatically"; if (anywayBtn) anywayBtn.style.display = "inline"; return; } if (anywayBtn) anywayBtn.style.display = "none"; - graphViewStatus.textContent = graphStatusText(data); + graphViewStatus.textContent = graphStatusText(data) + filterNote; renderGraph(toG6Data(data, collapsedComboIds(), foldEnabled())); } @@ -547,6 +589,9 @@ Graph Console } fetch("/dbg/actions/graph-data") .then(function (resp) { + if (resp.status === 404) { + throw new Error("no graph session (backend restarted?) — reload a file"); + } if (!resp.ok) throw new Error("graph-data HTTP " + resp.status); return resp.json(); }) @@ -697,6 +742,17 @@ Graph Console } } + let wsClosing = false; + let wsReconnectTimer = null; + + function scheduleReconnect() { + if (wsClosing || wsReconnectTimer) return; + wsReconnectTimer = setTimeout(function () { + wsReconnectTimer = null; + connect(); + }, 3000); + } + function connect() { ws = new WebSocket(wsUrl); wsStatus.textContent = "connecting…"; @@ -704,6 +760,9 @@ Graph Console ws.addEventListener("open", function () { wsStatus.textContent = "subscribed"; subscribe(); + // catch up on anything missed while disconnected + refreshSyncStatus(); + scheduleGraphRefetch(); }); ws.addEventListener("message", function (event) { @@ -711,7 +770,8 @@ Graph Console }); ws.addEventListener("close", function () { - wsStatus.textContent = "disconnected"; + wsStatus.textContent = wsClosing ? "disconnected" : "disconnected — retrying…"; + scheduleReconnect(); }); ws.addEventListener("error", function () { @@ -768,6 +828,24 @@ Graph Console html += ""; output.innerHTML = html; + + // Offer to view the result as an induced subgraph: any UUID appearing + // in any result cell selects that node in the loaded graph. + const uuidRe = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + const ids = new Set(); + (result.rows || []).forEach(function (row) { + row.forEach(function (cell) { + const found = String(cell).match(uuidRe); + if (found) found.forEach(function (m) { ids.add(m.toLowerCase()); }); + }); + }); + if (ids.size) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Show result in graph view (" + ids.size + " ids)"; + btn.addEventListener("click", function () { applyQueryFilter(ids); }); + output.appendChild(btn); + } } const queryForm = document.getElementById("graph-query-form"); @@ -857,12 +935,21 @@ Graph Console }); } + const filterResetBtn = document.getElementById("graph-filter-reset"); + if (filterResetBtn) { + filterResetBtn.addEventListener("click", function () { + graphFilterIds = null; + renderCurrent(); + }); + } + connect(); refreshSyncStatus(); renderGraphLegend(); refetchGraph(); window.addEventListener("beforeunload", function () { + wsClosing = true; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(encodeUnsubscribe(fileId)); ws.close();