diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 833d11c..74d2021 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -137,6 +137,10 @@ def graph_explorer_page() -> str:
+
+

Select a service, interface, dependency, or registered-only repo.

+ +
@@ -181,6 +185,8 @@ def graph_explorer_page() -> str: const detailSummary = document.getElementById("detail-summary"); const detailPills = document.getElementById("detail-pills"); const detailList = document.getElementById("detail-list"); + const orientationTitle = document.getElementById("orientation-title"); + const orientationList = document.getElementById("orientation-list"); const legend = document.getElementById("legend"); let cy = null; let selected = null; @@ -268,6 +274,8 @@ def graph_explorer_page() -> str: detailSummary.textContent = "No selection"; detailPills.innerHTML = ""; detailList.innerHTML = ""; + orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo."; + orientationList.innerHTML = ""; return; } const data = element.data(); @@ -291,6 +299,78 @@ def graph_explorer_page() -> str: .filter(([, value]) => value) .map(([key, value]) => `
  • ${escapeHtml(key)} ${escapeHtml(value)}
  • `) .join(""); + renderOrientation(element); + }; + + const renderOrientation = (element) => { + const data = element.data(); + const rows = []; + if (data.kind === "Repository" && data.lifecycle === "registered-only") { + orientationTitle.textContent = "Onboarding gap"; + rows.push(["repo", data.repo], ["next", "sync Fabric declarations or register a graph snapshot"]); + } else if (data.kind === "InterfaceDeclaration") { + orientationTitle.textContent = "Interface consumers"; + cy.edges().filter((edge) => + edge.data("target") === data.id && edge.data("edgeType") === "uses_interface" + ).forEach((edge) => { + const dependency = edge.data("source"); + const consumerEdge = cy.edges().filter((candidate) => + candidate.data("target") === dependency && candidate.data("edgeType") === "consumes" + )[0]; + const consumerId = consumerEdge ? consumerEdge.data("source") : ""; + const consumer = consumerId ? cy.getElementById(consumerId).data("name") || consumerId : "unknown"; + rows.push(["consumer", `${consumer} -> ${dependency}`]); + }); + if (rows.length === 0) rows.push(["consumer", "no accepted consumers in current graph"]); + } else if (data.kind === "ServiceDeclaration") { + orientationTitle.textContent = "Dependency path"; + cy.edges().filter((edge) => + edge.data("source") === data.id && edge.data("edgeType") === "consumes" + ).forEach((edge) => { + const dependency = edge.data("target"); + const providerEdges = cy.edges().filter((candidate) => + candidate.data("source") === dependency && String(candidate.data("edgeType")).startsWith("binds:") + ); + if (providerEdges.length === 0) { + rows.push(["requires", `${dependency} -> unresolved`]); + return; + } + providerEdges.forEach((providerEdge) => { + const target = providerEdge.data("target"); + const provider = cy.getElementById(target).data("name") || target; + rows.push(["requires", `${dependency} -> ${provider} (${providerEdge.data("edgeType")})`]); + }); + }); + if (rows.length === 0) rows.push(["requires", "no declared dependencies"]); + } else if (data.kind === "DependencyDeclaration") { + orientationTitle.textContent = "Dependency binding"; + cy.edges().filter((edge) => + edge.data("source") === data.id && ( + String(edge.data("edgeType")).startsWith("binds:") || + edge.data("edgeType") === "uses_interface" + ) + ).forEach((edge) => { + const target = edge.data("target"); + const targetName = cy.getElementById(target).data("name") || target; + rows.push([edge.data("edgeType"), targetName]); + }); + if (rows.length === 0) rows.push(["binding", "no provider binding in current graph"]); + } else if (data.kind === "CapabilityDeclaration") { + orientationTitle.textContent = "Provider surface"; + cy.edges().filter((edge) => + edge.data("target") === data.id && edge.data("edgeType") === "provides" + ).forEach((edge) => rows.push(["service", cy.getElementById(edge.data("source")).data("name") || edge.data("source")])); + cy.edges().filter((edge) => + edge.data("source") === data.id && edge.data("edgeType") === "available_via" + ).forEach((edge) => rows.push(["interface", cy.getElementById(edge.data("target")).data("name") || edge.data("target")])); + if (rows.length === 0) rows.push(["surface", "no provider surface in current graph"]); + } else { + orientationTitle.textContent = "Graph context"; + rows.push(["neighbors", `${element.neighborhood().nodes().length} connected nodes`]); + } + orientationList.innerHTML = rows + .map(([key, value]) => `
  • ${escapeHtml(key)} ${escapeHtml(value)}
  • `) + .join(""); }; const applyFocus = () => { diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index f7c0b82..c5ede49 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -190,6 +190,9 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert 'id="mode-select"' in page assert 'id="layout-select"' in page assert 'id="profile-select"' in page + assert 'id="orientation-list"' in page + assert "Interface consumers" in page + assert "Dependency path" in page assert "cytoscape.min.js" in page assert "/exports/graph-explorer/manifest" in page assert 'data-override="hide"' in page diff --git a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md index 8df996a..ec343a0 100644 --- a/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md +++ b/workplans/RAIL-FAB-WP-0008-interactive-fabric-map.md @@ -205,7 +205,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0008-T04 -status: in_progress +status: done priority: high state_hub_task_id: "75c1f234-026c-44ed-9c88-db39653b81e0" ``` @@ -228,7 +228,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0008-T05 -status: todo +status: in_progress priority: medium state_hub_task_id: "64fe53f1-fbea-4624-8f52-1b5e2a27cf67" ```