Split graph console in two columns; add file tree and fullscreen

Graph view moves to its own sticky right column (overrides .widget max-width). New /dbg/actions/graph-files endpoint lists teams -> projects -> files for the profile; the console renders it as a collapsible tree where clicking a file loads it. Maximize button fullscreens the graph panel and resizes G6 on fullscreenchange.

Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
This commit is contained in:
Álvaro Tejero Cantero 2026-07-15 18:52:04 +02:00
parent 66376406c9
commit 208a65c8df
No known key found for this signature in database
2 changed files with 185 additions and 15 deletions

View File

@ -11,16 +11,19 @@ Graph Console
</div>
</nav>
<main class="dashboard">
<section class="widget">
<section class="widget" style="max-width: none;">
<p><a href="/dbg">&larr; Back to debug</a></p>
<div style="display: flex; gap: 16px; align-items: flex-start;">
<div style="flex: 0 0 440px; min-width: 360px;">
<fieldset>
<legend>Load graph in memory</legend>
<desc>
Projects the Penpot file into an in-memory Ladybug database for this
admin session. Loading a new file replaces the previous one.
</desc>
<form method="post" action="/dbg/actions/graph-load">
<form id="graph-load-form" method="post" action="/dbg/actions/graph-load">
<div class="row">
<input type="text" style="width:420px" name="file-id"
placeholder="file-id"
@ -39,6 +42,12 @@ Graph Console
{% endif %}
</fieldset>
<fieldset>
<legend>Files</legend>
<desc>Teams &rarr; projects &rarr; files. Click a file to load it.</desc>
<div id="graph-files-tree" style="font-size: 13px;">Loading&hellip;</div>
</fieldset>
{% if session %}
<fieldset>
<legend>Loaded session</legend>
@ -88,19 +97,6 @@ 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">
@ -146,9 +142,111 @@ Graph Console
{% endif %}
</div>
{% endif %}
</div><!-- left column -->
{% if session %}
<div id="graph-view-column"
style="flex: 1 1 auto; min-width: 0; position: sticky; top: 8px;">
<fieldset id="graph-view-panel">
<legend>Graph view
<button type="button" id="graph-maximize" style="margin-left: 1em;">Maximize</button>
</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>
</div>
{% endif %}
</div><!-- flex row -->
</section>
</main>
<style>
#graph-view-panel:fullscreen {
background: #fff;
overflow: auto;
padding: 12px;
}
#graph-view-panel:fullscreen #graph-canvas {
height: calc(100vh - 110px) !important;
}
#graph-files-tree summary { cursor: pointer; }
#graph-files-tree ul { margin: 2px 0 4px 0; padding-left: 2em; }
#graph-files-tree a { text-decoration: none; }
#graph-files-tree a:hover { text-decoration: underline; }
</style>
<script>
(function () {
const tree = document.getElementById("graph-files-tree");
const loadForm = document.getElementById("graph-load-form");
const loadInput = loadForm ? loadForm.querySelector("input[name=file-id]") : null;
if (!tree) return;
function fileLink(file) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "#";
a.textContent = file.name;
a.title = file.id;
a.addEventListener("click", function (ev) {
ev.preventDefault();
if (loadInput) {
loadInput.value = file.id;
loadForm.submit();
}
});
li.appendChild(a);
return li;
}
fetch("/dbg/actions/graph-files")
.then(function (resp) {
if (!resp.ok) throw new Error("graph-files HTTP " + resp.status);
return resp.json();
})
.then(function (data) {
tree.textContent = "";
const teams = data.teams || [];
if (!teams.length) {
tree.textContent = "No files found.";
return;
}
teams.forEach(function (team) {
const teamEl = document.createElement("details");
const teamSummary = document.createElement("summary");
teamSummary.textContent = team.name;
teamEl.appendChild(teamSummary);
(team.projects || []).forEach(function (project) {
const projEl = document.createElement("details");
projEl.style.marginLeft = "1em";
const projSummary = document.createElement("summary");
projSummary.textContent = project.name;
projEl.appendChild(projSummary);
const list = document.createElement("ul");
(project.files || []).forEach(function (file) {
list.appendChild(fileLink(file));
});
projEl.appendChild(list);
teamEl.appendChild(projEl);
});
tree.appendChild(teamEl);
});
})
.catch(function (err) {
tree.textContent = "Failed to load file tree: " + err;
});
})();
</script>
{% if session %}
<script src="https://cdn.jsdelivr.net/npm/@antv/g6@5/dist/g6.min.js"></script>
<script>
@ -218,6 +316,7 @@ Graph Console
container: "graph-canvas",
data: g6data,
autoFit: "view",
autoResize: true,
padding: 20,
layout: { type: "antv-dagre", rankdir: "BT", nodesep: 10, ranksep: 40 },
node: {
@ -506,6 +605,33 @@ Graph Console
});
}
const maximizeBtn = document.getElementById("graph-maximize");
const graphPanel = document.getElementById("graph-view-panel");
if (maximizeBtn && graphPanel) {
maximizeBtn.addEventListener("click", function () {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
graphPanel.requestFullscreen().catch(function (err) {
graphViewStatus.textContent = "fullscreen rejected: " + err;
});
}
});
document.addEventListener("fullscreenchange", function () {
maximizeBtn.textContent =
document.fullscreenElement ? "Exit fullscreen" : "Maximize";
setTimeout(function () {
if (g6graph) {
const canvas = document.getElementById("graph-canvas");
try {
g6graph.setSize(canvas.clientWidth, canvas.clientHeight);
g6graph.fitView();
} catch (_err) { /* autoResize covers most cases */ }
}
}, 120);
});
}
connect();
refreshSyncStatus();
renderGraphLegend();

View File

@ -419,6 +419,49 @@
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode {:error "no-session"})}))
(def ^:private sql:graph-files
"select t.id as team_id, t.name as team_name,
p.id as project_id, p.name as project_name,
f.id as file_id, f.name as file_name
from team as t
join team_profile_rel as tpr on (tpr.team_id = t.id)
join project as p on (p.team_id = t.id)
join file as f on (f.project_id = p.id)
where tpr.profile_id = ?
and t.deleted_at is null
and p.deleted_at is null
and f.deleted_at is null
order by t.name, p.name, f.name
limit 500")
(defn- graph-files-tree
[rows]
(->> (group-by (juxt :team-id :team-name) rows)
(mapv (fn [[[team-id team-name] team-rows]]
{:id (str team-id)
:name team-name
:projects
(->> (group-by (juxt :project-id :project-name) team-rows)
(mapv (fn [[[project-id project-name] project-rows]]
{:id (str project-id)
:name project-name
:files (mapv (fn [{:keys [file-id file-name]}]
{:id (str file-id) :name file-name})
project-rows)}))
(sort-by :name)
(vec))}))
(sort-by :name)
(vec)))
(defn graph-files-handler
"List teams -> projects -> files reachable by the current profile, as
plain JSON for the graph console file tree."
[{:keys [::db/pool]} {:keys [::session/profile-id]}]
(let [rows (db/exec! pool [sql:graph-files profile-id])]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode {:teams (graph-files-tree rows)})}))
(defn- json-request?
[request]
(some-> request
@ -710,6 +753,7 @@
["/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)}]
["/graph-files" {:handler (partial graph-files-handler cfg)}]
["/file-import" {:handler (partial import-handler cfg)}]
["/file-raw-export-import" {:handler (partial raw-export-import-handler cfg)}]]]])