Add node inspector panel to graph console

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 <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-16 19:01:42 +02:00
parent dc6c97e446
commit 6b10ed065a
No known key found for this signature in database

View File

@ -183,6 +183,9 @@ Graph Console
<div id="graph-canvas"
style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff; overflow: hidden;"></div>
<div id="graph-view-status" style="color: #666; font-size: 12px;"></div>
<div id="graph-node-inspector"
style="display: none; font-size: 12px; margin-top: 4px; border: 1px solid #ccc;
padding: 6px; max-height: 40vh; overflow: auto;"></div>
<button type="button" id="graph-render-anyway" style="display: none;">Render anyway</button>
<button type="button" id="graph-filter-reset" style="display: none;">Show full graph</button>
</fieldset>
@ -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 = '<div style="display: flex; justify-content: space-between;">'
+ "<b>" + escapeHtml(table) + " " + escapeHtml(label) + "</b>"
+ '<button type="button" id="graph-inspector-close">close</button></div>'
+ '<div style="color: #666;">' + escapeHtml(id) + "</div>";
if (!rows.length) {
html += "<div>not in the graph DB (removed?)</div>";
} else {
const columns = result.columns || [];
let hidden = 0;
html += '<table border="1" cellpadding="2" cellspacing="0"'
+ ' style="border-collapse: collapse; margin-top: 4px;">';
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 += "<tr><td><code>" + escapeHtml(col.replace(/^n\./, ""))
+ "</code></td><td><code>" + escapeHtml(String(val))
+ "</code></td></tr>";
});
html += "</table>";
if (hidden) {
html += '<div style="color: #666;">' + hidden + " empty attrs hidden</div>";
}
}
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 () {