mirror of
https://github.com/penpot/penpot.git
synced 2026-08-18 18:58:40 +00:00
✨ 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:
parent
2ab1d07366
commit
7bb027e172
@ -88,6 +88,19 @@ Graph Console
|
|||||||
</table>
|
</table>
|
||||||
</fieldset>
|
</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>
|
<fieldset>
|
||||||
<legend>Cypher query</legend>
|
<legend>Cypher query</legend>
|
||||||
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
|
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
|
||||||
@ -137,6 +150,7 @@ Graph Console
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{% if session %}
|
{% if session %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@antv/g6@5/dist/g6.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const fileId = "{{session.file-id}}";
|
const fileId = "{{session.file-id}}";
|
||||||
@ -154,6 +168,122 @@ Graph Console
|
|||||||
|
|
||||||
let ws = null;
|
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) {
|
function encodeTransitUuid(uuid) {
|
||||||
return "~u" + uuid;
|
return "~u" + uuid;
|
||||||
}
|
}
|
||||||
@ -274,6 +404,7 @@ Graph Console
|
|||||||
|
|
||||||
appendChange(msg.revn, summarizeChanges(msg.changes));
|
appendChange(msg.revn, summarizeChanges(msg.changes));
|
||||||
setTimeout(refreshSyncStatus, 150);
|
setTimeout(refreshSyncStatus, 150);
|
||||||
|
scheduleGraphRefetch();
|
||||||
}
|
}
|
||||||
|
|
||||||
function subscribe() {
|
function subscribe() {
|
||||||
@ -377,6 +508,8 @@ Graph Console
|
|||||||
|
|
||||||
connect();
|
connect();
|
||||||
refreshSyncStatus();
|
refreshSyncStatus();
|
||||||
|
renderGraphLegend();
|
||||||
|
refetchGraph();
|
||||||
|
|
||||||
window.addEventListener("beforeunload", function () {
|
window.addEventListener("beforeunload", function () {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
[app.graph.ingest :as graph.ingest]
|
[app.graph.ingest :as graph.ingest]
|
||||||
[app.graph.ladybug :as ladybug]
|
[app.graph.ladybug :as ladybug]
|
||||||
|
[app.graph.schema.nodes :as nodes]
|
||||||
[app.graph.sync :as graph.sync]
|
[app.graph.sync :as graph.sync]
|
||||||
[app.msgbus :as mbus]
|
[app.msgbus :as mbus]
|
||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
@ -180,6 +181,54 @@
|
|||||||
:code :graph-session-not-loaded
|
:code :graph-session-not-loaded
|
||||||
:hint "load a file graph before running queries")))
|
: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
|
(defn console-context
|
||||||
"Build template data for the graph debug console page."
|
"Build template data for the graph debug console page."
|
||||||
[profile-id & {:keys [query query-result error message]}]
|
[profile-id & {:keys [query query-result error message]}]
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.features :as cfeat]
|
[app.common.features :as cfeat]
|
||||||
|
[app.common.json :as json]
|
||||||
[app.common.logging :as l]
|
[app.common.logging :as l]
|
||||||
[app.common.pprint :as pp]
|
[app.common.pprint :as pp]
|
||||||
[app.common.time :as ct]
|
[app.common.time :as ct]
|
||||||
@ -406,6 +407,18 @@
|
|||||||
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
::yres/headers {"content-type" "application/json; charset=utf-8"}
|
||||||
::yres/body (t/encode-str {:error "no-session"} {:type :json-verbose})}))
|
::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?
|
(defn- json-request?
|
||||||
[request]
|
[request]
|
||||||
(some-> request
|
(some-> request
|
||||||
@ -696,6 +709,7 @@
|
|||||||
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
|
["/graph-unload" {:handler (partial graph-unload-handler cfg)}]
|
||||||
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
|
["/graph-reload" {:handler (partial graph-reload-handler cfg)}]
|
||||||
["/graph-sync-status" {:handler (partial graph-sync-status-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-import" {:handler (partial import-handler cfg)}]
|
||||||
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
|
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user