mirror of
https://github.com/penpot/penpot.git
synced 2026-08-13 08:18:52 +00:00
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>
650 lines
21 KiB
Cheetah
650 lines
21 KiB
Cheetah
{% extends "app/templates/base.tmpl" %}
|
|
|
|
{% block title %}
|
|
Graph Console
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<nav>
|
|
<div class="title">
|
|
<h1>GRAPH CONSOLE (VERSION: {{version}})</h1>
|
|
</div>
|
|
</nav>
|
|
<main class="dashboard">
|
|
<section class="widget" style="max-width: none;">
|
|
<p><a href="/dbg">← 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 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"
|
|
value="{% if session %}{{session.file-id}}{% endif %}" />
|
|
</div>
|
|
<div class="row">
|
|
<input type="submit" value="Load" />
|
|
</div>
|
|
</form>
|
|
{% if session %}
|
|
<form method="post" action="/dbg/actions/graph-unload">
|
|
<div class="row">
|
|
<input type="submit" value="Unload" />
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Files</legend>
|
|
<desc>Teams → projects → files. Click a file to load it.</desc>
|
|
<div id="graph-files-tree" style="font-size: 13px;">Loading…</div>
|
|
</fieldset>
|
|
|
|
{% if session %}
|
|
<fieldset>
|
|
<legend>Loaded session</legend>
|
|
<desc>
|
|
<p>
|
|
File: <b>{{session.name}}</b> ({{session.file-id}})<br />
|
|
Loaded at revision: <b>{{session.revn}}</b><br />
|
|
Graph revision: <b id="graph-sync-revn">{% if session.graph-revn %}{{session.graph-revn}}{% else %}{{session.revn}}{% endif %}</b><br />
|
|
Schema: <b>{{session.schema-version}}</b><br />
|
|
Loaded at: <b>{{session.loaded-at}}</b>
|
|
</p>
|
|
<p id="graph-sync-status">
|
|
Feed: <b id="graph-ws-status">connecting…</b>
|
|
<span id="graph-sync-error" style="display:none; margin-left: 1em; color: #b91c1c;"></span>
|
|
</p>
|
|
<form id="graph-reload-form" method="post" action="/dbg/actions/graph-reload">
|
|
<input type="submit" value="Full reload (fallback)" />
|
|
</form>
|
|
{% if session.projection.stats %}
|
|
<p>
|
|
Projection:
|
|
documents={{session.projection.stats.documents}},
|
|
pages={{session.projection.stats.pages}},
|
|
shapes={{session.projection.stats.shapes}}
|
|
</p>
|
|
{% endif %}
|
|
</desc>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>File changes (live)</legend>
|
|
<desc>
|
|
Subscribes to the workspace WebSocket feed for visibility. The backend
|
|
applies supported changes incrementally to the in-memory Ladybug graph
|
|
via msgbus (<code>:file-change</code>).
|
|
</desc>
|
|
<div id="graph-changelog-empty" style="color: #666;">Waiting for changes…</div>
|
|
<table id="graph-changelog" border="1" cellpadding="4" cellspacing="0"
|
|
style="border-collapse: collapse; width: 100%; display: none;">
|
|
<thead>
|
|
<tr>
|
|
<th>revn</th>
|
|
<th>changes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="graph-changelog-body"></tbody>
|
|
</table>
|
|
</fieldset>
|
|
|
|
<fieldset>
|
|
<legend>Cypher query</legend>
|
|
<form id="graph-query-form" method="post" action="/dbg/actions/graph-query">
|
|
<div class="row">
|
|
<textarea name="query" rows="8" style="width:100%; font-family: monospace;">{{query}}</textarea>
|
|
</div>
|
|
<div class="row">
|
|
<input type="submit" value="Run query" />
|
|
</div>
|
|
</form>
|
|
</fieldset>
|
|
|
|
<div id="graph-query-output">
|
|
{% if error %}
|
|
<fieldset>
|
|
<legend>Error</legend>
|
|
<pre>{{error}}</pre>
|
|
</fieldset>
|
|
{% endif %}
|
|
|
|
{% if query-result %}
|
|
<fieldset>
|
|
<legend>Results ({{query-result.row-count}} rows{% if query-result.truncated? %}, truncated{% endif %})</legend>
|
|
<table border="1" cellpadding="4" cellspacing="0" style="border-collapse: collapse; width: 100%;">
|
|
<thead>
|
|
<tr>
|
|
{% for column in query-result.columns %}
|
|
<th>{{column}}</th>
|
|
{% endfor %}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for row in query-result.rows %}
|
|
<tr>
|
|
{% for cell in row %}
|
|
<td><code>{{cell}}</code></td>
|
|
{% endfor %}
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</fieldset>
|
|
{% 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>
|
|
(function () {
|
|
const fileId = "{{session.file-id}}";
|
|
const sessionId = crypto.randomUUID();
|
|
const wsScheme = location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = wsScheme + "//" + location.host
|
|
+ "/ws/notifications?session-id=" + sessionId;
|
|
|
|
const wsStatus = document.getElementById("graph-ws-status");
|
|
const syncRevnEl = document.getElementById("graph-sync-revn");
|
|
const syncErrorEl = document.getElementById("graph-sync-error");
|
|
const changelog = document.getElementById("graph-changelog");
|
|
const changelogBody = document.getElementById("graph-changelog-body");
|
|
const changelogEmpty = document.getElementById("graph-changelog-empty");
|
|
|
|
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",
|
|
autoResize: true,
|
|
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;
|
|
}
|
|
|
|
function encodeSubscribe(fileId) {
|
|
return JSON.stringify({
|
|
"~:type": "~:subscribe-file",
|
|
"~:file-id": encodeTransitUuid(fileId)
|
|
});
|
|
}
|
|
|
|
function encodeUnsubscribe(fileId) {
|
|
return JSON.stringify({
|
|
"~:type": "~:unsubscribe-file",
|
|
"~:file-id": encodeTransitUuid(fileId)
|
|
});
|
|
}
|
|
|
|
function parseTransitValue(value) {
|
|
if (typeof value === "string") {
|
|
if (value.startsWith("~:")) return value.slice(2);
|
|
if (value.startsWith("~u")) return value.slice(2);
|
|
}
|
|
if (Array.isArray(value)) return value.map(parseTransitValue);
|
|
if (value && typeof value === "object") return parseTransitMap(value);
|
|
return value;
|
|
}
|
|
|
|
function parseTransitMap(obj) {
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
const name = key.startsWith("~:") ? key.slice(2) : key;
|
|
out[name] = parseTransitValue(value);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function summarizeChange(change) {
|
|
const parts = [change.type];
|
|
if (change.id) parts.push("id=" + change.id);
|
|
if (change.obj && change.obj.type) parts.push("shape=" + change.obj.type);
|
|
if (change.operations && change.operations.length) {
|
|
const attrs = change.operations
|
|
.map(function (op) { return op.attr; })
|
|
.filter(Boolean);
|
|
if (attrs.length) parts.push("attrs=" + attrs.join(","));
|
|
}
|
|
return parts.join(" ");
|
|
}
|
|
|
|
function summarizeChanges(changes) {
|
|
if (!changes || !changes.length) return "(empty)";
|
|
return changes.map(summarizeChange).join("; ");
|
|
}
|
|
|
|
function summarizeSkipped(skipped) {
|
|
if (!skipped) return "";
|
|
const items = Array.isArray(skipped) ? skipped : [skipped];
|
|
return items.map(function (item) {
|
|
if (!item || typeof item !== "object") return String(item);
|
|
const type = item.type || "unknown";
|
|
const reason = item.reason ? " (" + item.reason + ")" : "";
|
|
return String(type) + reason;
|
|
}).join("; ");
|
|
}
|
|
|
|
function refreshSyncStatus() {
|
|
fetch("/dbg/actions/graph-sync-status")
|
|
.then(function (resp) { return resp.text(); })
|
|
.then(function (text) {
|
|
const status = parseTransitMap(JSON.parse(text));
|
|
if (status["graph-revn"] !== undefined) {
|
|
syncRevnEl.textContent = String(status["graph-revn"]);
|
|
}
|
|
if (status.sync && status.sync.error) {
|
|
syncErrorEl.style.display = "inline";
|
|
syncErrorEl.textContent = "sync error: " + status.sync.error;
|
|
} else if (status.sync && status.sync["last-skipped"]
|
|
&& summarizeSkipped(status.sync["last-skipped"])) {
|
|
syncErrorEl.style.display = "inline";
|
|
syncErrorEl.textContent =
|
|
"some changes skipped: "
|
|
+ summarizeSkipped(status.sync["last-skipped"])
|
|
+ " (use full reload if needed)";
|
|
} else {
|
|
syncErrorEl.style.display = "none";
|
|
syncErrorEl.textContent = "";
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function appendChange(revn, summary) {
|
|
changelogEmpty.style.display = "none";
|
|
changelog.style.display = "table";
|
|
|
|
const row = document.createElement("tr");
|
|
const revnCell = document.createElement("td");
|
|
const changesCell = document.createElement("td");
|
|
|
|
revnCell.textContent = String(revn);
|
|
changesCell.textContent = summary;
|
|
row.appendChild(revnCell);
|
|
row.appendChild(changesCell);
|
|
changelogBody.appendChild(row);
|
|
row.scrollIntoView({ block: "nearest" });
|
|
}
|
|
|
|
function handleMessage(raw) {
|
|
let msg;
|
|
try {
|
|
msg = parseTransitMap(JSON.parse(raw));
|
|
} catch (_err) {
|
|
return;
|
|
}
|
|
|
|
if (msg.type !== "file-change" || msg["file-id"] !== fileId) return;
|
|
|
|
appendChange(msg.revn, summarizeChanges(msg.changes));
|
|
setTimeout(refreshSyncStatus, 150);
|
|
scheduleGraphRefetch();
|
|
}
|
|
|
|
function subscribe() {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(encodeSubscribe(fileId));
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
ws = new WebSocket(wsUrl);
|
|
wsStatus.textContent = "connecting…";
|
|
|
|
ws.addEventListener("open", function () {
|
|
wsStatus.textContent = "subscribed";
|
|
subscribe();
|
|
});
|
|
|
|
ws.addEventListener("message", function (event) {
|
|
handleMessage(event.data);
|
|
});
|
|
|
|
ws.addEventListener("close", function () {
|
|
wsStatus.textContent = "disconnected";
|
|
});
|
|
|
|
ws.addEventListener("error", function () {
|
|
wsStatus.textContent = "error";
|
|
});
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function renderQueryOutput(data) {
|
|
const output = document.getElementById("graph-query-output");
|
|
if (!output) return;
|
|
|
|
if (data.error) {
|
|
output.innerHTML =
|
|
"<fieldset><legend>Error</legend>"
|
|
+ "<pre>" + escapeHtml(data.error) + "</pre></fieldset>";
|
|
return;
|
|
}
|
|
|
|
const result = data["query-result"];
|
|
if (!result) {
|
|
output.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const truncated = result["truncated?"] ? ", truncated" : "";
|
|
let html =
|
|
"<fieldset><legend>Results ("
|
|
+ escapeHtml(String(result["row-count"]))
|
|
+ " rows" + truncated + ")</legend>"
|
|
+ "<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\""
|
|
+ " style=\"border-collapse: collapse; width: 100%;\">"
|
|
+ "<thead><tr>";
|
|
|
|
(result.columns || []).forEach(function (column) {
|
|
html += "<th>" + escapeHtml(column) + "</th>";
|
|
});
|
|
html += "</tr></thead><tbody>";
|
|
|
|
(result.rows || []).forEach(function (row) {
|
|
html += "<tr>";
|
|
row.forEach(function (cell) {
|
|
html += "<td><code>" + escapeHtml(cell) + "</code></td>";
|
|
});
|
|
html += "</tr>";
|
|
});
|
|
|
|
html += "</tbody></table></fieldset>";
|
|
output.innerHTML = html;
|
|
}
|
|
|
|
const queryForm = document.getElementById("graph-query-form");
|
|
if (queryForm) {
|
|
queryForm.addEventListener("submit", function (event) {
|
|
event.preventDefault();
|
|
const formData = new FormData(queryForm);
|
|
fetch("/dbg/actions/graph-query", {
|
|
method: "POST",
|
|
headers: { "Accept": "application/json" },
|
|
body: formData
|
|
})
|
|
.then(function (resp) { return resp.text(); })
|
|
.then(function (text) {
|
|
renderQueryOutput(parseTransitMap(JSON.parse(text)));
|
|
})
|
|
.catch(function (err) {
|
|
renderQueryOutput({ error: String(err) });
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
refetchGraph();
|
|
|
|
window.addEventListener("beforeunload", function () {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(encodeUnsubscribe(fileId));
|
|
ws.close();
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|