Add G6 graph view to debug graph console

POC per work/g6/plan.md. New /dbg/actions/graph-data exports the in-memory Ladybug session as plain JSON (per-table node queries + multi-table IsChildOf match, row cap 100k with truncation flag). Console page renders it with AntV G6 v5 (jsDelivr CDN, antv-dagre BT layout, color+glyph per node table, validated palette) and refetches debounced on live :file-change messages.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-15 17:25:17 +02:00
parent 2ab1d07366
commit 7bb027e172
No known key found for this signature in database
3 changed files with 196 additions and 0 deletions

View File

@ -88,6 +88,19 @@ Graph Console
</table>
</fieldset>
<fieldset>
<legend>Graph view</legend>
<desc>
Renders the in-memory Ladybug graph with AntV G6 (CDN). Node color
and glyph encode the node table; edges are <code>IsChildOf</code>
(arrow points to parent). Redraws automatically after live changes.
</desc>
<div id="graph-legend" style="font-size: 12px; margin: 4px 0;"></div>
<div id="graph-canvas"
style="width: 100%; height: 600px; border: 1px solid #ccc; background: #fff;"></div>
<div id="graph-view-status" style="color: #666; font-size: 12px;"></div>
</fieldset>
<fieldset>
<legend>Cypher query</legend>
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
@ -137,6 +150,7 @@ Graph Console
</main>
{% if session %}
<script src="https://cdn.jsdelivr.net/npm/@antv/g6@5/dist/g6.min.js"></script>
<script>
(function () {
const fileId = "{{session.file-id}}";
@ -154,6 +168,122 @@ Graph Console
let ws = null;
// --- G6 graph view -------------------------------------------------
// Color+glyph per node table. Colors from the validated categorical
// palette; within-glyph pairs are the only ones color must separate
// alone (every node also carries a direct name label + legend).
const NODE_STYLES = {
"Document": { color: "#0b0b0b", glyph: "diamond", size: 28 },
"Page": { color: "#2a78d6", glyph: "rect", size: 22 },
"Frame": { color: "#008300", glyph: "hexagon", size: 18 },
"Group": { color: "#4a3aa7", glyph: "hexagon", size: 18 },
"Boolean": { color: "#eda100", glyph: "hexagon", size: 18 },
"SVGRaw": { color: "#767470", glyph: "hexagon", size: 18 },
"Rectangle": { color: "#eb6834", glyph: "rect", size: 14 },
"Circle": { color: "#1baf7a", glyph: "circle", size: 14 },
"Path": { color: "#e34948", glyph: "triangle", size: 14 },
"Text": { color: "#e87ba4", glyph: "circle", size: 14 },
"Image": { color: "#eda100", glyph: "star", size: 14 }
};
const FALLBACK_STYLE = { color: "#767470", glyph: "circle", size: 14 };
const graphViewStatus = document.getElementById("graph-view-status");
let g6graph = null;
let refetchTimer = null;
function nodeStyle(table) {
return NODE_STYLES[table] || FALLBACK_STYLE;
}
function toG6Data(data) {
return {
nodes: (data.nodes || []).map(function (n) {
return { id: n.id, data: { label: n.label, table: n.table } };
}),
edges: (data.edges || [])
.slice()
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); })
.map(function (e) {
return { source: e.source, target: e.target, data: { position: e.position } };
})
};
}
function renderGraph(g6data) {
if (g6graph) {
g6graph.setData(g6data);
return g6graph.render();
}
g6graph = new G6.Graph({
container: "graph-canvas",
data: g6data,
autoFit: "view",
padding: 20,
layout: { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40 },
node: {
type: function (d) { return nodeStyle(d.data.table).glyph; },
style: {
size: function (d) { return nodeStyle(d.data.table).size; },
fill: function (d) { return nodeStyle(d.data.table).color; },
stroke: "#52514e",
lineWidth: 0.5,
labelText: function (d) { return d.data.label; },
labelFontSize: 9,
labelFill: "#0b0b0b",
labelPlacement: "bottom"
}
},
edge: {
style: { stroke: "#b3b0a8", endArrow: true, endArrowSize: 6 }
},
behaviors: ["zoom-canvas", "drag-canvas", "drag-element"]
});
return g6graph.render();
}
function renderGraphLegend() {
const el = document.getElementById("graph-legend");
el.innerHTML = Object.keys(NODE_STYLES).map(function (table) {
const s = NODE_STYLES[table];
return '<span style="margin-right: 1em;">'
+ '<i style="display: inline-block; width: 10px; height: 10px;'
+ ' margin-right: 3px; border: 1px solid #52514e;'
+ ' background: ' + s.color + ';"></i>'
+ escapeHtml(table) + " (" + s.glyph + ")</span>";
}).join("");
}
function refetchGraph() {
if (typeof G6 === "undefined") {
graphViewStatus.textContent = "G6 failed to load (CDN unreachable)";
return;
}
fetch("/dbg/actions/graph-data")
.then(function (resp) {
if (!resp.ok) throw new Error("graph-data HTTP " + resp.status);
return resp.json();
})
.then(function (data) {
graphViewStatus.textContent =
data.nodes.length + " nodes, " + data.edges.length + " edges"
+ " (graph revn " + data.revn + ")"
+ (data.truncated ? " — WARNING: truncated at row cap, view is partial" : "");
return renderGraph(toG6Data(data));
})
.catch(function (err) {
graphViewStatus.textContent = "graph view error: " + err;
});
}
function scheduleGraphRefetch() {
if (refetchTimer) clearTimeout(refetchTimer);
refetchTimer = setTimeout(function () {
refetchTimer = null;
refetchGraph();
}, 400);
}
// --- end G6 graph view ---------------------------------------------
function encodeTransitUuid(uuid) {
return "~u" + uuid;
}
@ -274,6 +404,7 @@ Graph Console
appendChange(msg.revn, summarizeChanges(msg.changes));
setTimeout(refreshSyncStatus, 150);
scheduleGraphRefetch();
}
function subscribe() {
@ -377,6 +508,8 @@ Graph Console
connect();
refreshSyncStatus();
renderGraphLegend();
refetchGraph();
window.addEventListener("beforeunload", function () {
if (ws && ws.readyState === WebSocket.OPEN) {

View File

@ -12,6 +12,7 @@
[app.common.time :as ct]
[app.graph.ingest :as graph.ingest]
[app.graph.ladybug :as ladybug]
[app.graph.schema.nodes :as nodes]
[app.graph.sync :as graph.sync]
[app.msgbus :as mbus]
[clojure.string :as str]
@ -180,6 +181,54 @@
:code :graph-session-not-loaded
:hint "load a file graph before running queries")))
(def ^:private export-max-rows
"Row cap for graph-view export queries; far above expected per-file node
and edge counts. `:truncated` in the export signals when it was hit."
100000)
(defn- export-nodes
[conn]
(reduce
(fn [acc {:keys [table]}]
(let [stmt (str "MATCH (n:" (nodes/match-label table)
") RETURN n.id AS id, n.name AS name;")
{:keys [rows truncated?]}
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
(-> acc
(update :nodes into
(map (fn [[id label]]
{:id (str id) :label (str label) :table table}))
rows)
(update :truncated? #(or % truncated?)))))
{:nodes [] :truncated? false}
nodes/node-types))
(defn- export-edges
[conn]
(let [stmt (str "MATCH (a)-[r:IsChildOf]->(b) "
"RETURN a.id AS source, b.id AS target, r.position AS position;")
{:keys [rows truncated?]}
(ladybug/query-on-connection! conn stmt :max-rows export-max-rows)]
{:edges (mapv (fn [[source target position]]
{:source (str source) :target (str target) :position position})
rows)
:truncated? truncated?}))
(defn export-graph-data!
"Export the node/edge inventory of the in-memory graph for `profile-id`
as plain data for the debug graph view. Returns nil when no session is
loaded. Queries the Ladybug database (not the sync index) so the view
reflects actual DB state, including drift."
[profile-id]
(when-let [{:keys [conn file-id index]} (get @sessions (session-key profile-id))]
(let [{:keys [nodes] nodes-truncated? :truncated?} (export-nodes conn)
{:keys [edges] edges-truncated? :truncated?} (export-edges conn)]
{:file-id (str file-id)
:revn (:revn index)
:truncated (boolean (or nodes-truncated? edges-truncated?))
:nodes nodes
:edges edges})))
(defn console-context
"Build template data for the graph debug console page."
[profile-id & {:keys [query query-result error message]}]

View File

@ -13,6 +13,7 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.time :as ct]
@ -406,6 +407,18 @@
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})}))
(defn graph-data-handler
"Export the in-memory session graph as plain JSON (not transit) for the
G6 graph view embedded in the console page."
[_cfg {:keys [::session/profile-id]}]
(if-let [data (graph.debug/export-graph-data! profile-id)]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode data)}
{::yres/status 404
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode {:error "no-session"})}))
(defn- json-request?
[request]
(some-> request
@ -696,6 +709,7 @@
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
["/graph-sync-status" {:handler (partial graph-sync-status-handler cfg)}]
["/graph-data" {:handler (partial graph-data-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])