diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index 4c841de..38d42f1 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -245,6 +245,24 @@ def graph_explorer_page() -> str: } .detail-list { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; } .detail-list li { overflow-wrap: anywhere; } + .orientation-list { gap: 8px; } + .orientation-item { + border: 1px solid var(--line); + border-radius: 8px; + background: #fbfcfe; + padding: 8px; + } + .orientation-label { + color: var(--muted); + display: block; + font-size: 11px; + margin-bottom: 3px; + text-transform: uppercase; + } + .orientation-value { color: var(--text); line-height: 1.35; } + .orientation-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; } + .orientation-warning { border-color: #f59e0b; background: #fffbeb; } + .orientation-good { border-color: #5eead4; background: #f0fdfa; } .rule-panel { border: 1px solid var(--line); border-radius: 8px; @@ -400,7 +418,8 @@ def graph_explorer_page() -> str:

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

- + +
@@ -508,6 +527,7 @@ def graph_explorer_page() -> str: const detailList = document.getElementById("detail-list"); const orientationTitle = document.getElementById("orientation-title"); const orientationList = document.getElementById("orientation-list"); + const orientationActions = document.getElementById("orientation-actions"); const legend = document.getElementById("legend"); const profileStorageKey = "railiance.fabric.graphExplorer.profiles"; let cy = null; @@ -526,6 +546,7 @@ def graph_explorer_page() -> str: let currentProfileId = ""; let filterRules = []; let editingRuleId = ""; + let orientationContext = null; const escapeHtml = (value) => String(value ?? "") .replaceAll("&", "&").replaceAll("<", "<") @@ -780,6 +801,45 @@ def graph_explorer_page() -> str: return data.name || data.label || data.edgeType || data.id; }; + const nodeById = (id) => { + if (!cy || !id) return null; + const node = cy.getElementById(id); + return node && node.length > 0 ? node : null; + }; + + const nodeName = (id) => { + const node = nodeById(id); + return node ? elementLabel(node) : id; + }; + + const addContextElement = (ids, element) => { + if (!element || element.length === 0) return; + ids.add(element.id()); + }; + + const addContextNode = (ids, id) => { + const node = nodeById(id); + if (node) ids.add(node.id()); + }; + + const addContextEdge = (ids, edge) => { + addContextElement(ids, edge); + if (!edge || edge.length === 0) return; + addContextNode(ids, edge.data("source")); + addContextNode(ids, edge.data("target")); + }; + + const edgeCollection = (source = "", target = "", type = "") => cy ? cy.edges().filter((edge) => + (!source || edge.data("source") === source) && + (!target || edge.data("target") === target) && + (!type || edge.data("edgeType") === type) + ) : []; + + const collectionArray = (collection) => Array.from(collection || []); + + const orientationStateClass = (state) => + state === "warning" ? "orientation-warning" : state === "good" ? "orientation-good" : ""; + const renderedElementPosition = (element) => { if (!element || !cy) return null; if (element.isNode && element.isNode()) return element.renderedPosition(); @@ -968,6 +1028,21 @@ def graph_explorer_page() -> str: const selectedProfile = () => profiles.find((profile) => profile.id === currentProfileId) || null; + const viewStateSummary = () => { + const parts = []; + if (searchInput.value.trim()) parts.push("search"); + if ((modeSelect.value || "full") !== "full") parts.push(modeSelect.options[modeSelect.selectedIndex]?.textContent || "mode"); + if ((labelSelect.value || "auto") !== "auto") parts.push(`${labelSelect.value} labels`); + if (selectedNodeTypes().size !== allNodeTypes.length) parts.push("node filter"); + if (selectedEdgeTypes().size !== allEdgeTypes.length) parts.push("edge filter"); + if (reviewFilter.value) parts.push(`${reviewFilter.value} review`); + if (unresolvedFilter.value) parts.push("unresolved only"); + if (filterRules.length) parts.push(`${filterRules.length} rule${filterRules.length === 1 ? "" : "s"}`); + const overrideCount = Object.keys(manualOverrides).length; + if (overrideCount) parts.push(`${overrideCount} override${overrideCount === 1 ? "" : "s"}`); + return parts.length ? parts.join(", ") : "no filters"; + }; + const updateProfileSummary = (message = "") => { if (!supportsLocalProfiles()) { profileSummary.textContent = "Profile persistence unavailable for this host."; @@ -975,7 +1050,7 @@ def graph_explorer_page() -> str: } const profile = selectedProfile(); const prefix = profile ? `Loaded "${profile.name}".` : "Unsaved exploration."; - profileSummary.textContent = message || `${prefix} ${profiles.length} saved profile${profiles.length === 1 ? "" : "s"}.`; + profileSummary.textContent = message || `${prefix} Current state: ${viewStateSummary()}. ${profiles.length} saved view${profiles.length === 1 ? "" : "s"}.`; }; const updateProfileControls = () => { @@ -1013,7 +1088,7 @@ def graph_explorer_page() -> str: const saveCurrentProfile = (name, sourceProfile = null) => { const now = new Date().toISOString(); - const profileName = name.trim() || sourceProfile?.name || `Fabric view ${profiles.length + 1}`; + const profileName = name.trim() || sourceProfile?.name || orientationContext?.profileName || `Fabric view ${profiles.length + 1}`; if (sourceProfile) { const profile = { ...sourceProfile, @@ -1135,12 +1210,14 @@ def graph_explorer_page() -> str: const showDetails = (element) => { selected = element || null; if (!element) { + orientationContext = null; detailTitle.textContent = "Fabric Map"; detailSummary.textContent = "No selection"; detailPills.innerHTML = ""; detailList.innerHTML = ""; orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo."; orientationList.innerHTML = ""; + orientationActions.innerHTML = ""; updateLabelVisibility(); updateSelectionAnchor(); return; @@ -1174,73 +1251,202 @@ def graph_explorer_page() -> str: const renderOrientation = (element) => { const data = element.data(); + const contextIds = new Set([element.id()]); const rows = []; + let title = "Graph context"; + let profileName = `Fabric context: ${elementLabel(element)}`; 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"]); + title = "Onboarding gap"; + profileName = `Onboarding gap: ${data.repo || elementLabel(element)}`; + rows.push( + {label: "repo", value: data.repo || data.id}, + {label: "status", value: "registered without an accepted Fabric graph snapshot", state: "warning"}, + {label: "next", value: "add Fabric declarations or run registry sync once a snapshot exists"} + ); } 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}`]); + title = "Interface consumers and impact"; + profileName = `Interface impact: ${elementLabel(element)}`; + const attributes = data.attributes || {}; + if (attributes.service_id) { + addContextNode(contextIds, attributes.service_id); + rows.push({label: "provider service", value: nodeName(attributes.service_id), state: "good"}); + } + edgeCollection("", data.id, "available_via").forEach((edge) => { + addContextEdge(contextIds, edge); + const capability = edge.data("source"); + edgeCollection("", capability, "provides").forEach((providerEdge) => { + addContextEdge(contextIds, providerEdge); + rows.push({ + label: "provider surface", + value: `${nodeName(providerEdge.data("source"))} -> ${nodeName(capability)} -> ${elementLabel(element)}`, + path: true, + state: "good", + }); + }); }); - if (rows.length === 0) rows.push(["consumer", "no accepted consumers in current graph"]); + let consumerCount = 0; + edgeCollection("", data.id, "uses_interface").forEach((edge) => { + addContextEdge(contextIds, edge); + const dependency = edge.data("source"); + const dependencyNode = nodeById(dependency); + edgeCollection("", dependency, "consumes").forEach((consumerEdge) => { + consumerCount += 1; + addContextEdge(contextIds, consumerEdge); + rows.push({ + label: "consumer", + value: `${nodeName(consumerEdge.data("source"))} -> ${nodeName(dependency)} -> ${elementLabel(element)}`, + path: true, + state: dependencyNode?.data("unresolved") === true ? "warning" : "", + }); + }); + }); + if (consumerCount === 0) rows.push({label: "consumer", value: "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) => { + title = "Service dependency chain"; + profileName = `Service map: ${elementLabel(element)}`; + let dependencyCount = 0; + edgeCollection(data.id, "", "consumes").forEach((edge) => { + dependencyCount += 1; + addContextEdge(contextIds, edge); const dependency = edge.data("target"); const providerEdges = cy.edges().filter((candidate) => - candidate.data("source") === dependency && String(candidate.data("edgeType")).startsWith("binds:") + candidate.data("source") === dependency && ( + String(candidate.data("edgeType")).startsWith("binds:") || + candidate.data("edgeType") === "uses_interface" + ) ); if (providerEdges.length === 0) { - rows.push(["requires", `${dependency} -> unresolved`]); + rows.push({ + label: "requires", + value: `${elementLabel(element)} -> ${nodeName(dependency)} -> unresolved`, + path: true, + state: "warning", + }); return; } providerEdges.forEach((providerEdge) => { + addContextEdge(contextIds, providerEdge); const target = providerEdge.data("target"); - const provider = cy.getElementById(target).data("name") || target; - rows.push(["requires", `${dependency} -> ${provider} (${providerEdge.data("edgeType")})`]); + rows.push({ + label: "requires", + value: `${elementLabel(element)} -> ${nodeName(dependency)} -> ${nodeName(target)} (${providerEdge.data("edgeType")})`, + path: true, + state: String(providerEdge.data("edgeType")).includes("missing") ? "warning" : "good", + }); + }); + }); + if (dependencyCount === 0) rows.push({label: "requires", value: "no declared dependencies"}); + edgeCollection(data.id, "", "deployed_as").forEach((edge) => { + addContextEdge(contextIds, edge); + const deployment = edge.data("target"); + const runtimeEdges = edgeCollection(deployment, "", "runs_on"); + if (runtimeEdges.length === 0) { + rows.push({label: "deployment", value: `${nodeName(deployment)} -> no server binding`, path: true, state: "warning"}); + return; + } + runtimeEdges.forEach((runtimeEdge) => { + addContextEdge(contextIds, runtimeEdge); + rows.push({ + label: "runtime", + value: `${nodeName(deployment)} -> ${nodeName(runtimeEdge.data("target"))}`, + path: true, + state: "good", + }); }); }); - if (rows.length === 0) rows.push(["requires", "no declared dependencies"]); } else if (data.kind === "DependencyDeclaration") { - orientationTitle.textContent = "Dependency binding"; + title = "Dependency binding"; + profileName = `Dependency binding: ${elementLabel(element)}`; + edgeCollection("", data.id, "consumes").forEach((edge) => { + addContextEdge(contextIds, edge); + rows.push({ + label: "consumer", + value: `${nodeName(edge.data("source"))} -> ${elementLabel(element)}`, + path: true, + }); + }); cy.edges().filter((edge) => edge.data("source") === data.id && ( String(edge.data("edgeType")).startsWith("binds:") || edge.data("edgeType") === "uses_interface" ) ).forEach((edge) => { + addContextEdge(contextIds, edge); const target = edge.data("target"); - const targetName = cy.getElementById(target).data("name") || target; - rows.push([edge.data("edgeType"), targetName]); + rows.push({ + label: edge.data("edgeType"), + value: `${elementLabel(element)} -> ${nodeName(target)}`, + path: true, + state: String(edge.data("edgeType")).includes("missing") ? "warning" : "good", + }); }); - if (rows.length === 0) rows.push(["binding", "no provider binding in current graph"]); + if (rows.length === 0) rows.push({label: "binding", value: "no provider binding in current graph", state: "warning"}); } 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"]); + title = "Provider surface"; + profileName = `Provider surface: ${elementLabel(element)}`; + edgeCollection("", data.id, "provides").forEach((edge) => { + addContextEdge(contextIds, edge); + rows.push({label: "service", value: `${nodeName(edge.data("source"))} -> ${elementLabel(element)}`, path: true, state: "good"}); + }); + edgeCollection(data.id, "", "available_via").forEach((edge) => { + addContextEdge(contextIds, edge); + rows.push({label: "interface", value: `${elementLabel(element)} -> ${nodeName(edge.data("target"))}`, path: true, state: "good"}); + }); + if (rows.length === 0) rows.push({label: "surface", value: "no provider surface in current graph"}); } else { - orientationTitle.textContent = "Graph context"; - rows.push(["neighbors", `${element.neighborhood().nodes().length} connected nodes`]); + const neighborhood = element.neighborhood(); + neighborhood.forEach((item) => addContextElement(contextIds, item)); + rows.push({label: "neighbors", value: `${neighborhood.nodes().length} connected nodes`}); } + orientationContext = { + ids: Array.from(contextIds), + profileName, + }; + orientationTitle.textContent = `${title} (${orientationContext.ids.length} items)`; orientationList.innerHTML = rows - .map(([key, value]) => `
  • ${escapeHtml(key)} ${escapeHtml(value)}
  • `) + .map((row) => ` +
  • + ${escapeHtml(row.label)} + ${escapeHtml(row.value)} +
  • + `) .join(""); + orientationActions.innerHTML = ` + + + + + + `; + }; + + const applyOrientationContext = (action) => { + if (!cy || !orientationContext) return; + const contextIds = new Set(orientationContext.ids); + if (action === "focus") { + focusSet = contextIds; + modeSelect.value = "neighborhood"; + activeMode = "neighborhood"; + } else if (action === "highlight") { + contextIds.forEach((id) => { manualOverrides[id] = "highlight"; }); + } else if (action === "hide-other" || action === "remove-other") { + const otherState = action === "remove-other" ? "remove" : "hide"; + cy.elements().forEach((item) => { + manualOverrides[item.id()] = contextIds.has(item.id()) ? "show" : otherState; + }); + } else if (action === "name-view") { + if (supportsLocalProfiles()) { + profileNameInput.value = orientationContext.profileName; + updateProfileSummary(`Prepared view name "${orientationContext.profileName}".`); + } + return; + } + currentProfileId = ""; + profileSelect.value = ""; + applyFilters({redrawOnRemove: action === "remove-other"}); + updateProfileControls(); + updateProfileSummary(); + updateUrlState(); }; const applyFocus = () => { @@ -1547,6 +1753,11 @@ def graph_explorer_page() -> str: updateProfileSummary(); updateUrlState(); }); + orientationActions.addEventListener("click", (event) => { + const button = event.target.closest("[data-orientation-action]"); + if (!button) return; + applyOrientationContext(button.dataset.orientationAction); + }); 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", () => { diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 39e1f9e..11b08c2 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -241,8 +241,15 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None: assert 'id="profile-select"' in page assert 'id="profile-name"' in page assert 'id="orientation-list"' in page + assert 'id="orientation-actions"' in page + assert "Service dependency chain" in page assert "Interface consumers" in page - assert "Dependency path" in page + assert "Interface impact" in page + assert "applyOrientationContext" in page + assert "Focus Context" in page + assert "Highlight Context" in page + assert "Remove Other" in page + assert "viewStateSummary" in page assert "cytoscape.min.js" in page assert "layoutIdealLength" in page assert "layoutElasticity" in page diff --git a/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md b/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md index 75bd6f8..8633b5b 100644 --- a/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md +++ b/workplans/RAIL-FAB-WP-0009-graph-explorer-ui-refinement.md @@ -227,7 +227,7 @@ Acceptance notes: ```task id: RAIL-FAB-WP-0009-T07 -status: todo +status: done priority: medium state_hub_task_id: "6cf1fb8b-9d50-4550-942a-69bc29d14eaa" ```