290 lines
9.9 KiB
JavaScript
290 lines
9.9 KiB
JavaScript
/* Cortex-OS Umbrella panel — Cytoscape.js render of /api/umbrella graph.
|
|
* Consumes UI-stable schema v1.0 per CORTEX-OS-UMBRELLA-VIZ-PRD.md.
|
|
* Curator-maintained graph artifact at graph/umbrella.json.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
const TYPE_COLOR = {
|
|
doc: "#6ee7b7",
|
|
profile: "#fbbf24",
|
|
skill: "#a78bfa",
|
|
mcp_server: "#60a5fa",
|
|
sovereign_api: "#f97316",
|
|
cortex_tool: "#94a3b8",
|
|
external_dep: "#f87171",
|
|
credential: "#475569",
|
|
};
|
|
const TYPE_SHAPE = {
|
|
doc: "round-rectangle",
|
|
profile: "hexagon",
|
|
skill: "ellipse",
|
|
mcp_server: "diamond",
|
|
sovereign_api: "rectangle",
|
|
cortex_tool: "round-rectangle",
|
|
external_dep: "triangle",
|
|
credential: "vee",
|
|
};
|
|
const EDGE_STYLE = {
|
|
depends_on: { color: "#475569", style: "solid" },
|
|
governs: { color: "#fbbf24", style: "solid" },
|
|
consumes: { color: "#6ee7b7", style: "solid" },
|
|
produces: { color: "#a78bfa", style: "dashed" },
|
|
supersedes: { color: "#f87171", style: "dashed" },
|
|
cites: { color: "#38bdf8", style: "dotted" },
|
|
derives_from: { color: "#f59e0b", style: "dashed" },
|
|
};
|
|
|
|
let cy = null;
|
|
let graph = null;
|
|
const activeTypes = new Set();
|
|
window.__svrntyUmbrella = { ready: false, error: null, nodes: 0, edges: 0 };
|
|
|
|
async function loadGraph() {
|
|
const res = await fetch("/api/umbrella", { cache: "no-store" });
|
|
if (!res.ok) throw new Error("graph load failed: " + res.status);
|
|
return await res.json();
|
|
}
|
|
|
|
function renderStats(g) {
|
|
const s = g.stats || {};
|
|
const byType = s.by_type || {};
|
|
document.getElementById("stats").textContent =
|
|
`nodes ${s.total_nodes || g.nodes.length} · edges ${s.total_edges || g.edges.length} · ` +
|
|
Object.entries(byType).map(([k, v]) => `${k}:${v}`).join(" ");
|
|
document.getElementById("genInfo").textContent =
|
|
`generated ${g.generated_at || "?"} by ${g.generated_by || "?"}`;
|
|
}
|
|
|
|
function renderFilters(g) {
|
|
const types = [...new Set(g.nodes.map(n => n.type))].sort();
|
|
const wrap = document.getElementById("filters");
|
|
wrap.innerHTML = "";
|
|
types.forEach(t => {
|
|
activeTypes.add(t);
|
|
const chip = document.createElement("button");
|
|
chip.className = "chip active";
|
|
chip.dataset.type = t;
|
|
chip.style.borderColor = TYPE_COLOR[t] || "#475569";
|
|
chip.style.color = TYPE_COLOR[t] || "#e7eaf0";
|
|
chip.textContent = `${t} (${g.nodes.filter(n => n.type === t).length})`;
|
|
chip.addEventListener("click", () => toggleType(t, chip));
|
|
wrap.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
function toggleType(t, chipEl) {
|
|
if (activeTypes.has(t)) { activeTypes.delete(t); chipEl.classList.remove("active"); }
|
|
else { activeTypes.add(t); chipEl.classList.add("active"); }
|
|
applyFilter();
|
|
}
|
|
|
|
function applyFilter() {
|
|
if (!cy) return;
|
|
cy.batch(() => {
|
|
cy.nodes().forEach(n => {
|
|
const visible = activeTypes.has(n.data("type"));
|
|
n.style("display", visible ? "element" : "none");
|
|
});
|
|
cy.edges().forEach(e => {
|
|
const s = e.source().style("display");
|
|
const t = e.target().style("display");
|
|
e.style("display", (s === "element" && t === "element") ? "element" : "none");
|
|
});
|
|
});
|
|
}
|
|
|
|
function buildCyElements(g) {
|
|
const nodes = g.nodes.map(n => ({
|
|
data: { id: n.id, label: n.label || n.id, type: n.type, raw: n },
|
|
}));
|
|
const edges = g.edges.map((e, i) => ({
|
|
data: { id: `e${i}`, source: e.source, target: e.target, type: e.type, raw: e },
|
|
}));
|
|
return [...nodes, ...edges];
|
|
}
|
|
|
|
function renderGraph(g) {
|
|
cy = cytoscape({
|
|
container: document.getElementById("cy"),
|
|
elements: buildCyElements(g),
|
|
wheelSensitivity: 0.2,
|
|
style: [
|
|
{
|
|
selector: "node",
|
|
style: {
|
|
"background-color": ele => TYPE_COLOR[ele.data("type")] || "#475569",
|
|
"shape": ele => TYPE_SHAPE[ele.data("type")] || "ellipse",
|
|
"label": "data(label)",
|
|
"color": "#e7eaf0",
|
|
"text-valign": "bottom",
|
|
"text-margin-y": 4,
|
|
"font-size": 10,
|
|
"font-family": "ui-monospace, monospace",
|
|
"width": 28,
|
|
"height": 28,
|
|
"border-width": 1,
|
|
"border-color": "#0f1115",
|
|
},
|
|
},
|
|
{
|
|
selector: "node:selected",
|
|
style: { "border-width": 3, "border-color": "#fbbf24" },
|
|
},
|
|
{
|
|
selector: "edge",
|
|
style: {
|
|
"width": 1,
|
|
"line-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569",
|
|
"line-style": ele => (EDGE_STYLE[ele.data("type")] || {}).style || "solid",
|
|
"curve-style": "bezier",
|
|
"target-arrow-shape": "triangle",
|
|
"target-arrow-color": ele => (EDGE_STYLE[ele.data("type")] || {}).color || "#475569",
|
|
"opacity": 0.5,
|
|
},
|
|
},
|
|
{
|
|
selector: "edge:selected",
|
|
style: { "opacity": 1, "width": 2 },
|
|
},
|
|
],
|
|
layout: { name: "cose", animate: false, idealEdgeLength: 80, nodeRepulsion: 12000 },
|
|
});
|
|
cy.on("tap", "node", (evt) => openSidePanel(evt.target.data("raw")));
|
|
cy.on("tap", (evt) => { if (evt.target === cy) closeSidePanel(); });
|
|
}
|
|
|
|
async function openSidePanel(node) {
|
|
document.getElementById("side").dataset.open = "true";
|
|
document.getElementById("sideTitle").textContent = node.label || node.id;
|
|
const meta = document.getElementById("sideMeta");
|
|
meta.innerHTML = "";
|
|
const metaFields = ["type", "tier", "status", "category", "owner", "role", "pin"];
|
|
metaFields.forEach(f => {
|
|
if (node[f] != null) {
|
|
const d = document.createElement("div");
|
|
d.innerHTML = `<b>${f}:</b> ${node[f]}`;
|
|
meta.appendChild(d);
|
|
}
|
|
});
|
|
if (node.governance) {
|
|
Object.entries(node.governance).forEach(([k, v]) => {
|
|
if (v != null) {
|
|
const d = document.createElement("div");
|
|
d.innerHTML = `<b>gov.${k}:</b> ${typeof v === "string" ? v : JSON.stringify(v)}`;
|
|
meta.appendChild(d);
|
|
}
|
|
});
|
|
}
|
|
if (node.description) {
|
|
const d = document.createElement("div");
|
|
d.style.marginTop = "8px";
|
|
d.style.color = "#e7eaf0";
|
|
d.textContent = node.description;
|
|
meta.appendChild(d);
|
|
}
|
|
// Body — fetch markdown if source_path
|
|
const body = document.getElementById("sideBody");
|
|
if (node.source_path) {
|
|
body.textContent = "loading…";
|
|
try {
|
|
const r = await fetch("/api/umbrella/doc?path=" + encodeURIComponent(node.source_path), { cache: "no-store" });
|
|
if (r.ok) {
|
|
const j = await r.json();
|
|
body.textContent = j.body || "(empty)";
|
|
} else {
|
|
body.textContent = "(no doc — " + r.status + ")";
|
|
}
|
|
} catch (e) {
|
|
body.textContent = "(fetch error: " + e + ")";
|
|
}
|
|
} else {
|
|
body.textContent = "(no source_path)";
|
|
}
|
|
// Edges
|
|
const edges = document.getElementById("sideEdges");
|
|
edges.innerHTML = "";
|
|
if (!cy) return;
|
|
const cyNode = cy.getElementById(node.id);
|
|
const outgoing = cyNode.connectedEdges().filter(e => e.source().id() === node.id);
|
|
const incoming = cyNode.connectedEdges().filter(e => e.target().id() === node.id);
|
|
if (outgoing.length) {
|
|
edges.innerHTML += `<h3>outgoing (${outgoing.length})</h3><ul>` +
|
|
outgoing.map(e =>
|
|
`<li><span class="etype">${e.data("type")}</span><button data-id="${e.target().id()}">${e.target().id()}</button></li>`
|
|
).join("") + `</ul>`;
|
|
}
|
|
if (incoming.length) {
|
|
edges.innerHTML += `<h3>incoming (${incoming.length})</h3><ul>` +
|
|
incoming.map(e =>
|
|
`<li><span class="etype">${e.data("type")}</span><button data-id="${e.source().id()}">${e.source().id()}</button></li>`
|
|
).join("") + `</ul>`;
|
|
}
|
|
edges.querySelectorAll("button[data-id]").forEach(b => {
|
|
b.addEventListener("click", () => {
|
|
const id = b.dataset.id;
|
|
const n = graph.nodes.find(n => n.id === id);
|
|
if (n) { openSidePanel(n); cy.getElementById(id).select(); }
|
|
});
|
|
});
|
|
}
|
|
|
|
function closeSidePanel() {
|
|
document.getElementById("side").dataset.open = "false";
|
|
if (cy) cy.elements().unselect();
|
|
}
|
|
|
|
function bindControls() {
|
|
document.getElementById("closeSide").addEventListener("click", closeSidePanel);
|
|
document.getElementById("reset").addEventListener("click", () => cy.fit(null, 30));
|
|
document.querySelectorAll("button[data-layout]").forEach(b => {
|
|
b.addEventListener("click", () => {
|
|
const name = b.dataset.layout;
|
|
const opts = name === "cose"
|
|
? { name, animate: true, idealEdgeLength: 80, nodeRepulsion: 12000 }
|
|
: name === "breadthfirst"
|
|
? { name, animate: true, directed: true, padding: 10 }
|
|
: { name, animate: true };
|
|
cy.layout(opts).run();
|
|
});
|
|
});
|
|
const search = document.getElementById("search");
|
|
search.addEventListener("input", () => {
|
|
const q = search.value.trim().toLowerCase();
|
|
cy.batch(() => {
|
|
cy.nodes().forEach(n => {
|
|
const hit = !q || n.data("id").toLowerCase().includes(q) ||
|
|
(n.data("raw").description || "").toLowerCase().includes(q);
|
|
n.style("opacity", hit ? 1 : 0.15);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
graph = await loadGraph();
|
|
renderStats(graph);
|
|
renderFilters(graph);
|
|
renderGraph(graph);
|
|
bindControls();
|
|
window.__svrntyUmbrella = {
|
|
ready: true,
|
|
error: null,
|
|
nodes: graph.nodes.length,
|
|
edges: graph.edges.length,
|
|
};
|
|
} catch (e) {
|
|
document.getElementById("stats").textContent = "load failed: " + e.message;
|
|
window.__svrntyUmbrella = { ready: false, error: String(e.message || e), nodes: 0, edges: 0 };
|
|
console.error("[umbrella]", e);
|
|
}
|
|
}
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|