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", () => {