generated from coulomb/repo-seed
feat: show zone resolver diagnostics
This commit is contained in:
@@ -182,6 +182,12 @@ Useful warnings for the graph explorer include:
|
||||
- local-only surfaces that appear in shared or production scenarios;
|
||||
- conflicting port or host claims within the same deployment scenario.
|
||||
|
||||
Zone resolvers should also expose scoped diagnostics in zone detail panels. The
|
||||
initial diagnostic set includes empty zone seed sets, visible nodes matched by
|
||||
multiple zone definitions, and edges crossing zone boundaries. Attraction
|
||||
diagnostics such as multiple attraction candidates or depth-limit stops belong
|
||||
to the same resolver diagnostic channel when attraction rules are enabled.
|
||||
|
||||
## Repo-Scoping Compatibility
|
||||
|
||||
Repo-scoping can adapt without a rewrite because its current graph payload
|
||||
|
||||
@@ -1111,6 +1111,14 @@ def graph_explorer_page() -> str:
|
||||
};
|
||||
};
|
||||
|
||||
const zoneDiagnosticCodes = {
|
||||
emptySeedSet: "ZONE_EMPTY_SEED_SET",
|
||||
nodeSeededByMultipleZones: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
|
||||
nodeAttractedByMultipleZones: "ZONE_NODE_ATTRACTED_BY_MULTIPLE_ZONES",
|
||||
attractionDepthLimitReached: "ZONE_ATTRACTION_DEPTH_LIMIT_REACHED",
|
||||
edgeCrossesZoneBoundary: "ZONE_EDGE_CROSSES_ZONE_BOUNDARY",
|
||||
};
|
||||
|
||||
const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
|
||||
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
|
||||
if (grouping === "accessZone") {
|
||||
@@ -1176,7 +1184,7 @@ def graph_explorer_page() -> str:
|
||||
if (candidates.length > 1) {
|
||||
const diagnostic = {
|
||||
severity: "warning",
|
||||
code: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
|
||||
code: zoneDiagnosticCodes.nodeSeededByMultipleZones,
|
||||
message: `${elementLabel(node)} matched multiple zone definitions.`,
|
||||
};
|
||||
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
|
||||
@@ -1189,6 +1197,15 @@ def graph_explorer_page() -> str:
|
||||
zone.nodes.push(node);
|
||||
addElementToZone(chosen.id, node);
|
||||
});
|
||||
zones.forEach((zone) => {
|
||||
if (!zone.nodes.length) {
|
||||
zone.diagnostics.push({
|
||||
severity: "warning",
|
||||
code: zoneDiagnosticCodes.emptySeedSet,
|
||||
message: `${zone.label} has no visible seed nodes.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
|
||||
const data = zoneElementData(edge);
|
||||
const zoneIds = new Set(
|
||||
@@ -1198,6 +1215,16 @@ def graph_explorer_page() -> str:
|
||||
);
|
||||
const sourceZoneId = assignments.get(edge.data("source"));
|
||||
const targetZoneId = assignments.get(edge.data("target"));
|
||||
if ((sourceZoneId || targetZoneId) && sourceZoneId !== targetZoneId) {
|
||||
const diagnostic = {
|
||||
severity: "info",
|
||||
code: zoneDiagnosticCodes.edgeCrossesZoneBoundary,
|
||||
message: `${elementLabel(edge)} crosses a zone boundary.`,
|
||||
};
|
||||
[sourceZoneId, targetZoneId]
|
||||
.filter(Boolean)
|
||||
.forEach((zoneId) => zones.get(zoneId)?.diagnostics.push(diagnostic));
|
||||
}
|
||||
if (sourceZoneId) zoneIds.add(sourceZoneId);
|
||||
if (targetZoneId) zoneIds.add(targetZoneId);
|
||||
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
|
||||
@@ -1321,6 +1348,8 @@ def graph_explorer_page() -> str:
|
||||
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
|
||||
`${elementLabel(element)}: ${warning}`
|
||||
));
|
||||
const diagnostics = (zone.diagnostics || [])
|
||||
.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`);
|
||||
const rows = [
|
||||
["visible nodes", String(visibleNodes.length)],
|
||||
["deployment environments", valuesFor("deploymentEnvironment")],
|
||||
@@ -1328,12 +1357,14 @@ def graph_explorer_page() -> str:
|
||||
["access zones", valuesFor("accessZone")],
|
||||
["routing authorities", valuesFor("routingAuthority")],
|
||||
["policy authorities", valuesFor("policyAuthority")],
|
||||
...diagnostics.slice(0, 8).map((diagnostic) => ["diagnostic", diagnostic]),
|
||||
...warnings.slice(0, 8).map((warning) => ["warning", warning]),
|
||||
];
|
||||
if (diagnostics.length > 8) rows.push(["diagnostic", `${diagnostics.length - 8} additional zone diagnostics`]);
|
||||
if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
|
||||
detailList.innerHTML = rows
|
||||
.filter(([, value]) => value)
|
||||
.map(([key, value]) => `<li class="${key === "warning" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
||||
.map(([key, value]) => `<li class="${key === "warning" || key === "diagnostic" ? "orientation-warning" : ""}"><strong>${escapeHtml(key)}</strong> ${escapeHtml(value)}</li>`)
|
||||
.join("");
|
||||
|
||||
const contextIds = new Set(visibleNodes.map((node) => node.id()));
|
||||
@@ -1346,6 +1377,7 @@ def graph_explorer_page() -> str:
|
||||
orientationList.innerHTML = [
|
||||
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
|
||||
{label: "zone", value: zone.label},
|
||||
{label: "diagnostics", value: diagnostics.length ? String(diagnostics.length) : "none", state: diagnostics.length ? "warning" : "good"},
|
||||
{label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
|
||||
].map((row) => `
|
||||
<li class="orientation-item ${orientationStateClass(row.state)}">
|
||||
|
||||
@@ -332,6 +332,7 @@ def resolve_zones(
|
||||
edge_records,
|
||||
enabled_definitions,
|
||||
seed_node_ids_by_zone_id,
|
||||
diagnostics,
|
||||
)
|
||||
for candidate in attraction_candidates:
|
||||
candidates_by_node_id[candidate.node_id].append(candidate)
|
||||
@@ -406,6 +407,16 @@ def resolve_zones(
|
||||
internal_edge_ids_by_zone_id[source_zone_id].append(edge.id)
|
||||
continue
|
||||
if source_zone_id or target_zone_id:
|
||||
zone_ids = tuple(sorted(str(zone_id) for zone_id in {source_zone_id, target_zone_id} - {None}))
|
||||
diagnostics.append(
|
||||
ZoneDiagnostic(
|
||||
severity="INFO",
|
||||
code="ZONE_EDGE_CROSSES_ZONE_BOUNDARY",
|
||||
message=f"Edge {edge.id} crosses a zone boundary.",
|
||||
edge_id=edge.id,
|
||||
zone_ids=zone_ids,
|
||||
)
|
||||
)
|
||||
boundary_edges.append(
|
||||
ZoneBoundaryEdge(
|
||||
edge_id=edge.id,
|
||||
@@ -468,6 +479,7 @@ def _attraction_candidates(
|
||||
edge_records: tuple[_EdgeRecord, ...],
|
||||
enabled_definitions: tuple[tuple[int, ZoneDefinition], ...],
|
||||
seed_node_ids_by_zone_id: Mapping[str, set[str]],
|
||||
diagnostics: list[ZoneDiagnostic],
|
||||
) -> list[_Candidate]:
|
||||
nodes_by_id = {node.id: node for node in node_records}
|
||||
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
|
||||
@@ -476,6 +488,7 @@ def _attraction_candidates(
|
||||
adjacency[edge.target].append(edge)
|
||||
|
||||
candidates: dict[tuple[str, str], _Candidate] = {}
|
||||
depth_limit_diagnostics: set[tuple[str, str]] = set()
|
||||
for definition_order, definition in enabled_definitions:
|
||||
seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set())
|
||||
if not seed_node_ids:
|
||||
@@ -486,6 +499,27 @@ def _attraction_candidates(
|
||||
while queue:
|
||||
node_id, depth = queue.popleft()
|
||||
if depth >= rule.depth:
|
||||
if _has_matching_attraction_neighbor(
|
||||
node_id,
|
||||
adjacency.get(node_id, []),
|
||||
nodes_by_id,
|
||||
rule,
|
||||
):
|
||||
key = (definition.id, node_id)
|
||||
if key not in depth_limit_diagnostics:
|
||||
depth_limit_diagnostics.add(key)
|
||||
diagnostics.append(
|
||||
ZoneDiagnostic(
|
||||
severity="INFO",
|
||||
code="ZONE_ATTRACTION_DEPTH_LIMIT_REACHED",
|
||||
message=(
|
||||
f"Zone {definition.id} reached attraction depth "
|
||||
f"{rule.depth} at node {node_id}."
|
||||
),
|
||||
node_id=node_id,
|
||||
zone_ids=(definition.id,),
|
||||
)
|
||||
)
|
||||
continue
|
||||
for edge in adjacency.get(node_id, []):
|
||||
if not _edge_matches_attraction_rule(edge, rule):
|
||||
@@ -521,6 +555,24 @@ def _attraction_candidates(
|
||||
return sorted(candidates.values(), key=lambda candidate: (candidate.node_id, candidate.zone_id))
|
||||
|
||||
|
||||
def _has_matching_attraction_neighbor(
|
||||
node_id: str,
|
||||
edges: Iterable[_EdgeRecord],
|
||||
nodes_by_id: Mapping[str, _NodeRecord],
|
||||
rule: ZoneAttractionRule,
|
||||
) -> bool:
|
||||
for edge in edges:
|
||||
if not _edge_matches_attraction_rule(edge, rule):
|
||||
continue
|
||||
neighbor_id = _neighbor_for_direction(node_id, edge, rule.direction)
|
||||
if not neighbor_id:
|
||||
continue
|
||||
neighbor = nodes_by_id.get(neighbor_id)
|
||||
if neighbor and _rule_matches(neighbor.data, rule.node_filter, empty_matches=True):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _node_records(nodes: Iterable[Mapping[str, Any]]) -> tuple[_NodeRecord, ...]:
|
||||
records: list[_NodeRecord] = []
|
||||
for order, element in enumerate(nodes):
|
||||
|
||||
@@ -417,6 +417,11 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
|
||||
assert "zoneForData" in page
|
||||
assert "defaultZoneDefinitions" in page
|
||||
assert "resolveZoneInstances" in page
|
||||
assert "zoneDiagnosticCodes" in page
|
||||
assert "ZONE_EMPTY_SEED_SET" in page
|
||||
assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in page
|
||||
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in page
|
||||
assert "zone.diagnostics" in page
|
||||
assert 'normalize: "deploymentEnvironment"' in page
|
||||
assert "zoneBoundsForNodes" in page
|
||||
assert "renderZoneOverlay" in page
|
||||
|
||||
@@ -91,6 +91,9 @@ def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
|
||||
assert resolution.boundary_edges[0].edge_id == "edge.prod-test"
|
||||
assert resolution.boundary_edges[0].source_zone_id == "prod"
|
||||
assert resolution.boundary_edges[0].target_zone_id == "test"
|
||||
assert "ZONE_EDGE_CROSSES_ZONE_BOUNDARY" in {
|
||||
diagnostic.code for diagnostic in resolution.diagnostics
|
||||
}
|
||||
|
||||
|
||||
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
|
||||
@@ -128,6 +131,9 @@ def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
|
||||
assert "far" not in resolution.node_assignments
|
||||
assert resolution.zone_by_id("prod").internal_edge_ids == ("edge.seed-near",)
|
||||
assert resolution.zone_by_id("prod").boundary_edge_ids == ("edge.near-far",)
|
||||
assert "ZONE_ATTRACTION_DEPTH_LIMIT_REACHED" in {
|
||||
diagnostic.code for diagnostic in resolution.diagnostics
|
||||
}
|
||||
|
||||
|
||||
def test_resolver_keeps_seed_membership_over_attraction() -> None:
|
||||
@@ -229,3 +235,19 @@ def test_resolver_serializes_resolution() -> None:
|
||||
assert serialized["zones"][0]["id"] == "prod"
|
||||
assert serialized["node_assignments"]["svc"]["zone_id"] == "prod"
|
||||
assert serialized["node_assignments"]["svc"]["reason"] == "seed"
|
||||
|
||||
|
||||
def test_resolver_reports_empty_zone_seed_set() -> None:
|
||||
resolution = resolve_zones(
|
||||
nodes=[_node("svc", deploymentEnvironment="dev")],
|
||||
edges=[],
|
||||
zone_definitions=[
|
||||
{
|
||||
"id": "prod",
|
||||
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert resolution.zone_by_id("prod").node_ids == ()
|
||||
assert "ZONE_EMPTY_SEED_SET" in {diagnostic.code for diagnostic in resolution.diagnostics}
|
||||
|
||||
@@ -127,7 +127,7 @@ preserving edge evidence in zone details.
|
||||
|
||||
```task
|
||||
id: RAIL-FAB-WP-0022-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9"
|
||||
```
|
||||
@@ -146,6 +146,12 @@ Diagnostics should include at least:
|
||||
Expected result: zone detail panels show scoped diagnostics, and tests verify
|
||||
that diagnostics are generated by the resolver rather than ad hoc UI checks.
|
||||
|
||||
Result: Added resolver diagnostics for empty seed sets, overlapping zone
|
||||
membership, attraction depth-limit stops, and boundary-crossing edges. The graph
|
||||
explorer now surfaces scoped zone diagnostics in the selected zone detail panel
|
||||
and orientation context, with assertions proving diagnostics come from the zone
|
||||
resolver path.
|
||||
|
||||
## Task 5: Persist Zone View Settings In Profiles
|
||||
|
||||
```task
|
||||
|
||||
Reference in New Issue
Block a user