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 { display: grid; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
.detail-list li { overflow-wrap: anywhere; } .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 { .rule-panel {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 8px;
@@ -400,7 +418,8 @@ def graph_explorer_page() -> str:
</section> </section>
<section class="section"> <section class="section">
<p id="orientation-title" class="meta">Select a service, interface, dependency, or registered-only repo.</p> <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>
<section class="section"> <section class="section">
<div class="button-row"> <div class="button-row">
@@ -508,6 +527,7 @@ def graph_explorer_page() -> str:
const detailList = document.getElementById("detail-list"); const detailList = document.getElementById("detail-list");
const orientationTitle = document.getElementById("orientation-title"); const orientationTitle = document.getElementById("orientation-title");
const orientationList = document.getElementById("orientation-list"); const orientationList = document.getElementById("orientation-list");
const orientationActions = document.getElementById("orientation-actions");
const legend = document.getElementById("legend"); const legend = document.getElementById("legend");
const profileStorageKey = "railiance.fabric.graphExplorer.profiles"; const profileStorageKey = "railiance.fabric.graphExplorer.profiles";
let cy = null; let cy = null;
@@ -526,6 +546,7 @@ def graph_explorer_page() -> str:
let currentProfileId = ""; let currentProfileId = "";
let filterRules = []; let filterRules = [];
let editingRuleId = ""; let editingRuleId = "";
let orientationContext = null;
const escapeHtml = (value) => String(value ?? "") const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;").replaceAll("<", "&lt;") .replaceAll("&", "&amp;").replaceAll("<", "&lt;")
@@ -780,6 +801,45 @@ def graph_explorer_page() -> str:
return data.name || data.label || data.edgeType || data.id; 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) => { const renderedElementPosition = (element) => {
if (!element || !cy) return null; if (!element || !cy) return null;
if (element.isNode && element.isNode()) return element.renderedPosition(); 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 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 = "") => { const updateProfileSummary = (message = "") => {
if (!supportsLocalProfiles()) { if (!supportsLocalProfiles()) {
profileSummary.textContent = "Profile persistence unavailable for this host."; profileSummary.textContent = "Profile persistence unavailable for this host.";
@@ -975,7 +1050,7 @@ def graph_explorer_page() -> str:
} }
const profile = selectedProfile(); const profile = selectedProfile();
const prefix = profile ? `Loaded "${profile.name}".` : "Unsaved exploration."; 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 = () => { const updateProfileControls = () => {
@@ -1013,7 +1088,7 @@ def graph_explorer_page() -> str:
const saveCurrentProfile = (name, sourceProfile = null) => { const saveCurrentProfile = (name, sourceProfile = null) => {
const now = new Date().toISOString(); 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) { if (sourceProfile) {
const profile = { const profile = {
...sourceProfile, ...sourceProfile,
@@ -1135,12 +1210,14 @@ def graph_explorer_page() -> str:
const showDetails = (element) => { const showDetails = (element) => {
selected = element || null; selected = element || null;
if (!element) { if (!element) {
orientationContext = null;
detailTitle.textContent = "Fabric Map"; detailTitle.textContent = "Fabric Map";
detailSummary.textContent = "No selection"; detailSummary.textContent = "No selection";
detailPills.innerHTML = ""; detailPills.innerHTML = "";
detailList.innerHTML = ""; detailList.innerHTML = "";
orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo."; orientationTitle.textContent = "Select a service, interface, dependency, or registered-only repo.";
orientationList.innerHTML = ""; orientationList.innerHTML = "";
orientationActions.innerHTML = "";
updateLabelVisibility(); updateLabelVisibility();
updateSelectionAnchor(); updateSelectionAnchor();
return; return;
@@ -1174,73 +1251,202 @@ def graph_explorer_page() -> str:
const renderOrientation = (element) => { const renderOrientation = (element) => {
const data = element.data(); const data = element.data();
const contextIds = new Set([element.id()]);
const rows = []; const rows = [];
let title = "Graph context";
let profileName = `Fabric context: ${elementLabel(element)}`;
if (data.kind === "Repository" && data.lifecycle === "registered-only") { if (data.kind === "Repository" && data.lifecycle === "registered-only") {
orientationTitle.textContent = "Onboarding gap"; title = "Onboarding gap";
rows.push(["repo", data.repo], ["next", "sync Fabric declarations or register a graph snapshot"]); 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") { } else if (data.kind === "InterfaceDeclaration") {
orientationTitle.textContent = "Interface consumers"; title = "Interface consumers and impact";
cy.edges().filter((edge) => profileName = `Interface impact: ${elementLabel(element)}`;
edge.data("target") === data.id && edge.data("edgeType") === "uses_interface" const attributes = data.attributes || {};
).forEach((edge) => { if (attributes.service_id) {
const dependency = edge.data("source"); addContextNode(contextIds, attributes.service_id);
const consumerEdge = cy.edges().filter((candidate) => rows.push({label: "provider service", value: nodeName(attributes.service_id), state: "good"});
candidate.data("target") === dependency && candidate.data("edgeType") === "consumes" }
)[0]; edgeCollection("", data.id, "available_via").forEach((edge) => {
const consumerId = consumerEdge ? consumerEdge.data("source") : ""; addContextEdge(contextIds, edge);
const consumer = consumerId ? cy.getElementById(consumerId).data("name") || consumerId : "unknown"; const capability = edge.data("source");
rows.push(["consumer", `${consumer} -> ${dependency}`]); 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") { } else if (data.kind === "ServiceDeclaration") {
orientationTitle.textContent = "Dependency path"; title = "Service dependency chain";
cy.edges().filter((edge) => profileName = `Service map: ${elementLabel(element)}`;
edge.data("source") === data.id && edge.data("edgeType") === "consumes" let dependencyCount = 0;
).forEach((edge) => { edgeCollection(data.id, "", "consumes").forEach((edge) => {
dependencyCount += 1;
addContextEdge(contextIds, edge);
const dependency = edge.data("target"); const dependency = edge.data("target");
const providerEdges = cy.edges().filter((candidate) => 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) { if (providerEdges.length === 0) {
rows.push(["requires", `${dependency} -> unresolved`]); rows.push({
label: "requires",
value: `${elementLabel(element)} -> ${nodeName(dependency)} -> unresolved`,
path: true,
state: "warning",
});
return; return;
} }
providerEdges.forEach((providerEdge) => { providerEdges.forEach((providerEdge) => {
addContextEdge(contextIds, providerEdge);
const target = providerEdge.data("target"); const target = providerEdge.data("target");
const provider = cy.getElementById(target).data("name") || target; rows.push({
rows.push(["requires", `${dependency} -> ${provider} (${providerEdge.data("edgeType")})`]); 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") { } 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) => cy.edges().filter((edge) =>
edge.data("source") === data.id && ( edge.data("source") === data.id && (
String(edge.data("edgeType")).startsWith("binds:") || String(edge.data("edgeType")).startsWith("binds:") ||
edge.data("edgeType") === "uses_interface" edge.data("edgeType") === "uses_interface"
) )
).forEach((edge) => { ).forEach((edge) => {
addContextEdge(contextIds, edge);
const target = edge.data("target"); const target = edge.data("target");
const targetName = cy.getElementById(target).data("name") || target; rows.push({
rows.push([edge.data("edgeType"), targetName]); 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") { } else if (data.kind === "CapabilityDeclaration") {
orientationTitle.textContent = "Provider surface"; title = "Provider surface";
cy.edges().filter((edge) => profileName = `Provider surface: ${elementLabel(element)}`;
edge.data("target") === data.id && edge.data("edgeType") === "provides" edgeCollection("", data.id, "provides").forEach((edge) => {
).forEach((edge) => rows.push(["service", cy.getElementById(edge.data("source")).data("name") || edge.data("source")])); addContextEdge(contextIds, edge);
cy.edges().filter((edge) => rows.push({label: "service", value: `${nodeName(edge.data("source"))} -> ${elementLabel(element)}`, path: true, state: "good"});
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")])); edgeCollection(data.id, "", "available_via").forEach((edge) => {
if (rows.length === 0) rows.push(["surface", "no provider surface in current graph"]); 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 { } else {
orientationTitle.textContent = "Graph context"; const neighborhood = element.neighborhood();
rows.push(["neighbors", `${element.neighborhood().nodes().length} connected nodes`]); 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 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(""); .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 = () => { const applyFocus = () => {
@@ -1547,6 +1753,11 @@ def graph_explorer_page() -> str:
updateProfileSummary(); updateProfileSummary();
updateUrlState(); 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='fit']").addEventListener("click", () => cy && cy.fit(cy.elements(":visible"), 48));
document.querySelector("[data-action='focus']").addEventListener("click", applyFocus); document.querySelector("[data-action='focus']").addEventListener("click", applyFocus);
document.querySelector("[data-action='clear']").addEventListener("click", () => { 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-select"' in page
assert 'id="profile-name"' in page assert 'id="profile-name"' in page
assert 'id="orientation-list"' 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 "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 "cytoscape.min.js" in page
assert "layoutIdealLength" in page assert "layoutIdealLength" in page
assert "layoutElasticity" in page assert "layoutElasticity" in page

View File

@@ -227,7 +227,7 @@ Acceptance notes:
```task ```task
id: RAIL-FAB-WP-0009-T07 id: RAIL-FAB-WP-0009-T07
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "6cf1fb8b-9d50-4550-942a-69bc29d14eaa" state_hub_task_id: "6cf1fb8b-9d50-4550-942a-69bc29d14eaa"
``` ```