Polish graph explorer orientation workflows

This commit is contained in:
2026-05-19 01:46:23 +02:00
parent 33f0a52068
commit 93593a5b56
3 changed files with 261 additions and 43 deletions

View File

@@ -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:
</section>
<section class="section">
<p id="orientation-title" class="meta">Select a service, interface, dependency, or registered-only repo.</p>
<ul id="orientation-list" class="detail-list"></ul>
<ul id="orientation-list" class="detail-list orientation-list"></ul>
<div id="orientation-actions" class="button-row"></div>
</section>
<section class="section">
<div class="button-row">
@@ -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("&", "&amp;").replaceAll("<", "&lt;")
@@ -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]) => `<li><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
.map((row) => `
<li class="orientation-item ${orientationStateClass(row.state)}">
<span class="orientation-label">${escapeHtml(row.label)}</span>
<span class="orientation-value ${row.path ? "orientation-path" : ""}">${escapeHtml(row.value)}</span>
</li>
`)
.join("");
orientationActions.innerHTML = `
<button type="button" data-orientation-action="focus">Focus Context</button>
<button type="button" data-orientation-action="highlight">Highlight Context</button>
<button type="button" data-orientation-action="hide-other">Hide Other</button>
<button type="button" data-orientation-action="remove-other">Remove Other</button>
<button type="button" data-orientation-action="name-view">Name View</button>
`;
};
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", () => {

View File

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

View File

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