generated from coulomb/repo-seed
Polish graph explorer orientation workflows
This commit is contained in:
@@ -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("&", "&").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]) => `<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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user