diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 59aae2d..833d11c 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -27,7 +27,7 @@ def graph_explorer_page() -> str: .shell { display: grid; grid-template-rows: auto 1fr; height: 100vh; min-height: 620px; } .toolbar { display: grid; - grid-template-columns: minmax(180px, 1.2fr) repeat(3, minmax(120px, .6fr)) auto auto auto; + grid-template-columns: minmax(180px, 1.2fr) repeat(6, minmax(110px, .55fr)) auto auto auto; gap: 8px; align-items: center; padding: 10px 12px; @@ -108,9 +108,18 @@ def graph_explorer_page() -> str:
+ + + @@ -136,6 +145,14 @@ def graph_explorer_page() -> str:
+
+

Profile persistence unavailable for this host.

+
+ + + +
+

Hidden 0

@@ -151,9 +168,13 @@ def graph_explorer_page() -> str: const canvas = document.getElementById("graph-canvas"); const popup = document.getElementById("popup"); const searchInput = document.getElementById("search"); + const modeSelect = document.getElementById("mode-select"); + const layoutSelect = document.getElementById("layout-select"); const layerFilter = document.getElementById("layer-filter"); const reviewFilter = document.getElementById("review-filter"); const unresolvedFilter = document.getElementById("unresolved-filter"); + const profileSelect = document.getElementById("profile-select"); + const profileSummary = document.getElementById("profile-summary"); const counts = document.getElementById("counts"); const hiddenSummary = document.getElementById("hidden-summary"); const detailTitle = document.getElementById("detail-title"); @@ -166,6 +187,7 @@ def graph_explorer_page() -> str: let focusSet = null; let manualOverrides = {}; let layerColors = {}; + let activeMode = "full"; const escapeHtml = (value) => String(value ?? "") .replaceAll("&", "&").replaceAll("<", "<") @@ -178,6 +200,8 @@ def graph_explorer_page() -> str: const matchesFilters = (element) => { const data = element.data(); + const modeSet = visibleSetForMode(); + if (modeSet && !modeSet.has(element.id())) return false; const text = searchInput.value.trim().toLowerCase(); if (text && !elementText(data).includes(text)) return false; if (layerFilter.value && data.layer !== layerFilter.value) return false; @@ -187,6 +211,30 @@ def graph_explorer_page() -> str: return true; }; + const visibleSetForMode = () => { + if (!cy) return null; + if (focusSet) return focusSet; + if (activeMode === "full") return null; + if (activeMode === "onboarding-gaps") { + return new Set(cy.elements().filter((element) => + element.data("lifecycle") === "registered-only" || element.data("unresolved") === true + ).map((element) => element.id())); + } + if (activeMode === "unresolved") { + return new Set(cy.elements().filter((element) => element.data("unresolved") === true).map((element) => element.id())); + } + if (!selected) return null; + if (activeMode === "selected-path") { + const collection = selected.union(selected.predecessors()).union(selected.successors()); + return new Set(collection.map((element) => element.id())); + } + if (activeMode === "neighborhood") { + const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood()); + return new Set(collection.map((element) => element.id())); + } + return null; + }; + const applyFilters = () => { if (!cy) return; const hiddenNodes = new Set(); @@ -249,9 +297,21 @@ def graph_explorer_page() -> str: if (!cy || !selected) return; const collection = selected.union(selected.neighborhood()).union(selected.neighborhood().neighborhood()); focusSet = new Set(collection.map((item) => item.id())); + modeSelect.value = "neighborhood"; + activeMode = "neighborhood"; applyFilters(); }; + const runLayout = () => { + if (!cy) return; + cy.elements().stop(); + const name = layoutSelect.value || "cose"; + const options = name === "breadthfirst" + ? {name, directed: true, padding: 48, animate: false} + : {name, padding: 48, animate: false}; + cy.layout(options).run(); + }; + const renderLegend = (layers) => { legend.innerHTML = layers.map((layer) => `${escapeHtml(layer.label)}` @@ -263,6 +323,19 @@ def graph_explorer_page() -> str: fetch(manifestUrl).then((response) => response.json()), fetch(graphUrl).then((response) => response.json()) ]); + (manifest.modes || [{id: "full", label: "Full"}]).forEach((mode) => { + const option = document.createElement("option"); + option.value = mode.id; + option.textContent = mode.label; + modeSelect.appendChild(option); + }); + profileSelect.disabled = manifest.profile_persistence !== "host"; + document.querySelectorAll("[data-profile-action]").forEach((button) => { + button.disabled = manifest.profile_persistence !== "host"; + }); + profileSummary.textContent = manifest.profile_persistence === "host" + ? "Profiles are persisted by this host." + : "Profile persistence unavailable for this host; use filters and overrides for local exploration."; layerColors = Object.fromEntries((manifest.layers || []).map((layer) => [layer.id, layer.color])); (manifest.layers || []).forEach((layer) => { const option = document.createElement("option"); @@ -278,7 +351,7 @@ def graph_explorer_page() -> str: cy = cytoscape({ container: canvas, elements, - layout: {name: "cose", animate: false, randomize: false}, + layout: {name: layoutSelect.value || "cose", animate: false, randomize: false}, style: [ {selector: "node", style: { "background-color": "data(color)", @@ -324,15 +397,24 @@ def graph_explorer_page() -> str: popup.style.display = "none"; }); applyFilters(); + runLayout(); }; [searchInput, layerFilter, reviewFilter, unresolvedFilter].forEach((control) => { control.addEventListener("input", () => { focusSet = null; applyFilters(); }); }); + modeSelect.addEventListener("input", () => { + activeMode = modeSelect.value || "full"; + focusSet = null; + applyFilters(); + }); + layoutSelect.addEventListener("input", runLayout); document.querySelector("[data-action='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48)); document.querySelector("[data-action='focus']").addEventListener("click", applyFocus); document.querySelector("[data-action='clear']").addEventListener("click", () => { searchInput.value = ""; + modeSelect.value = "full"; + activeMode = "full"; layerFilter.value = ""; reviewFilter.value = ""; unresolvedFilter.value = ""; diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 15c877b..f7c0b82 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -187,9 +187,13 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert payload["metrics"]["registered_only_repo_count"] == 1 assert content_type.startswith("text/html") assert 'id="graph-canvas"' in page + assert 'id="mode-select"' in page + assert 'id="layout-select"' in page + assert 'id="profile-select"' in page assert "cytoscape.min.js" in page assert "/exports/graph-explorer/manifest" in page assert 'data-override="hide"' in page + assert 'data-profile-action="save"' in page finally: server.shutdown() server.server_close()