@@ -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"
```