From 6b10ed065a66c4a045dfaf953fde462bad5fc68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Tejero=20Cantero?= Date: Thu, 16 Jul 2026 19:01:42 +0200 Subject: [PATCH] :sparkles: Add node inspector panel to graph console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a node fetches its full attribute row (MATCH (n:`Table` {id: uuid(...)}) RETURN n.*) through the query endpoint and renders non-null attrs into a panel under the canvas (count of empty attrs noted). Panel over tooltip: projected tables carry ~80 columns, and the panel persists for reading without obstructing the graph. Table/id are validated before Cypher interpolation; the listener is re-attached on every instance recreation. Signed-off-by: Álvaro Tejero Cantero --- .../app/templates/graph-console.tmpl | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/backend/resources/app/templates/graph-console.tmpl b/backend/resources/app/templates/graph-console.tmpl index b40922d986..f45928b951 100644 --- a/backend/resources/app/templates/graph-console.tmpl +++ b/backend/resources/app/templates/graph-console.tmpl @@ -183,6 +183,9 @@ Graph Console
+ @@ -879,6 +882,12 @@ Graph Console }; if (layoutCfg) opts.layout = layoutCfg; g6graph = new G6.Graph(opts); + // Re-attached on every instance creation (layout/animation switches + // destroy and recreate the graph). + g6graph.on("node:click", function (e) { + const id = e.target && e.target.id; + if (id) showNodeInspector(String(id)); + }); return g6graph.render().catch(function (_err) { /* see above */ }); @@ -1019,6 +1028,81 @@ Graph Console }); } + // --- node inspector -------------------------------------------------- + // Click a node → its full attribute row from the graph DB, rendered in + // a panel under the canvas. Panel over tooltip: the projected tables + // carry ~80 columns — too much for a tooltip, and the panel persists + // for side-by-side reading without obstructing the graph. + function runConsoleQuery(query) { + const formData = new FormData(); + formData.append("query", query); + return fetch("/dbg/actions/graph-query", { + method: "POST", + headers: { "Accept": "application/json" }, + body: formData + }) + .then(function (resp) { return resp.text(); }) + .then(function (text) { return parseTransitMap(JSON.parse(text)); }); + } + + function showNodeInspector(id) { + const panel = document.getElementById("graph-node-inspector"); + if (!panel || !g6graph) return; + let datum = null; + try { datum = g6graph.getNodeData(id); } catch (_err) { return; } + const table = datum && datum.data && datum.data.table; + const label = (datum && datum.data && datum.data.label) || ""; + // Both values are interpolated into Cypher — accept only what our own + // export produces (bare table names, UUID ids). + if (!table || !/^[A-Za-z]+$/.test(table)) return; + if (!/^[0-9a-f-]{36}$/i.test(id)) return; + panel.style.display = "block"; + panel.textContent = "loading " + id + "…"; + runConsoleQuery("MATCH (n:`" + table + "` {id: uuid('" + id + "')}) RETURN n.*;") + .then(function (data) { + if (data.error) { + panel.textContent = "inspector error: " + data.error; + return; + } + const result = data["query-result"]; + const rows = (result && result.rows) || []; + let html = '
' + + "" + escapeHtml(table) + " " + escapeHtml(label) + "" + + '
' + + '
' + escapeHtml(id) + "
"; + if (!rows.length) { + html += "
not in the graph DB (removed?)
"; + } else { + const columns = result.columns || []; + let hidden = 0; + html += ''; + columns.forEach(function (col, i) { + const val = rows[0][i]; + // Ladybug's value->clj fallback renders SQL nulls as "NULL". + if (val === null || val === undefined || val === "" || val === "NULL") { + hidden += 1; + return; + } + html += ""; + }); + html += "
" + escapeHtml(col.replace(/^n\./, "")) + + "" + escapeHtml(String(val)) + + "
"; + if (hidden) { + html += '
' + hidden + " empty attrs hidden
"; + } + } + panel.innerHTML = html; + document.getElementById("graph-inspector-close") + .addEventListener("click", function () { panel.style.display = "none"; }); + }) + .catch(function (err) { + panel.textContent = "inspector error: " + err; + }); + } + // --- end node inspector ---------------------------------------------- + function scheduleGraphRefetch() { if (refetchTimer) clearTimeout(refetchTimer); refetchTimer = setTimeout(function () {