feat: show zone resolver diagnostics

This commit is contained in:
2026-05-25 00:09:18 +02:00
parent f1cd74dc7b
commit 1ad432270b
6 changed files with 126 additions and 3 deletions

View File

@@ -182,6 +182,12 @@ Useful warnings for the graph explorer include:
- local-only surfaces that appear in shared or production scenarios; - local-only surfaces that appear in shared or production scenarios;
- conflicting port or host claims within the same deployment scenario. - 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 Compatibility
Repo-scoping can adapt without a rewrite because its current graph payload Repo-scoping can adapt without a rewrite because its current graph payload

View File

@@ -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) => { const zoneDefinitionsForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment; if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") { if (grouping === "accessZone") {
@@ -1176,7 +1184,7 @@ def graph_explorer_page() -> str:
if (candidates.length > 1) { if (candidates.length > 1) {
const diagnostic = { const diagnostic = {
severity: "warning", severity: "warning",
code: "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES", code: zoneDiagnosticCodes.nodeSeededByMultipleZones,
message: `${elementLabel(node)} matched multiple zone definitions.`, message: `${elementLabel(node)} matched multiple zone definitions.`,
}; };
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic)); candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
@@ -1189,6 +1197,15 @@ def graph_explorer_page() -> str:
zone.nodes.push(node); zone.nodes.push(node);
addElementToZone(chosen.id, 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) => { elements.filter((element) => element.isEdge && element.isEdge()).forEach((edge) => {
const data = zoneElementData(edge); const data = zoneElementData(edge);
const zoneIds = new Set( const zoneIds = new Set(
@@ -1198,6 +1215,16 @@ def graph_explorer_page() -> str:
); );
const sourceZoneId = assignments.get(edge.data("source")); const sourceZoneId = assignments.get(edge.data("source"));
const targetZoneId = assignments.get(edge.data("target")); 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 (sourceZoneId) zoneIds.add(sourceZoneId);
if (targetZoneId) zoneIds.add(targetZoneId); if (targetZoneId) zoneIds.add(targetZoneId);
zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge)); zoneIds.forEach((zoneId) => addElementToZone(zoneId, edge));
@@ -1321,6 +1348,8 @@ def graph_explorer_page() -> str:
.flatMap((element) => zoneWarningsForData(element.data()).map((warning) => .flatMap((element) => zoneWarningsForData(element.data()).map((warning) =>
`${elementLabel(element)}: ${warning}` `${elementLabel(element)}: ${warning}`
)); ));
const diagnostics = (zone.diagnostics || [])
.map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`);
const rows = [ const rows = [
["visible nodes", String(visibleNodes.length)], ["visible nodes", String(visibleNodes.length)],
["deployment environments", valuesFor("deploymentEnvironment")], ["deployment environments", valuesFor("deploymentEnvironment")],
@@ -1328,12 +1357,14 @@ def graph_explorer_page() -> str:
["access zones", valuesFor("accessZone")], ["access zones", valuesFor("accessZone")],
["routing authorities", valuesFor("routingAuthority")], ["routing authorities", valuesFor("routingAuthority")],
["policy authorities", valuesFor("policyAuthority")], ["policy authorities", valuesFor("policyAuthority")],
...diagnostics.slice(0, 8).map((diagnostic) => ["diagnostic", diagnostic]),
...warnings.slice(0, 8).map((warning) => ["warning", warning]), ...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`]); if (warnings.length > 8) rows.push(["warning", `${warnings.length - 8} additional route warnings`]);
detailList.innerHTML = rows detailList.innerHTML = rows
.filter(([, value]) => value) .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(""); .join("");
const contextIds = new Set(visibleNodes.map((node) => node.id())); const contextIds = new Set(visibleNodes.map((node) => node.id()));
@@ -1346,6 +1377,7 @@ def graph_explorer_page() -> str:
orientationList.innerHTML = [ orientationList.innerHTML = [
{label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"}, {label: "grouping", value: ruleAttributeLabels[zone.field] || humanize(zone.field), state: "good"},
{label: "zone", value: zone.label}, {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"}, {label: "warnings", value: warnings.length ? String(warnings.length) : "none", state: warnings.length ? "warning" : "good"},
].map((row) => ` ].map((row) => `
<li class="orientation-item ${orientationStateClass(row.state)}"> <li class="orientation-item ${orientationStateClass(row.state)}">

View File

@@ -332,6 +332,7 @@ def resolve_zones(
edge_records, edge_records,
enabled_definitions, enabled_definitions,
seed_node_ids_by_zone_id, seed_node_ids_by_zone_id,
diagnostics,
) )
for candidate in attraction_candidates: for candidate in attraction_candidates:
candidates_by_node_id[candidate.node_id].append(candidate) 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) internal_edge_ids_by_zone_id[source_zone_id].append(edge.id)
continue continue
if source_zone_id or target_zone_id: 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( boundary_edges.append(
ZoneBoundaryEdge( ZoneBoundaryEdge(
edge_id=edge.id, edge_id=edge.id,
@@ -468,6 +479,7 @@ def _attraction_candidates(
edge_records: tuple[_EdgeRecord, ...], edge_records: tuple[_EdgeRecord, ...],
enabled_definitions: tuple[tuple[int, ZoneDefinition], ...], enabled_definitions: tuple[tuple[int, ZoneDefinition], ...],
seed_node_ids_by_zone_id: Mapping[str, set[str]], seed_node_ids_by_zone_id: Mapping[str, set[str]],
diagnostics: list[ZoneDiagnostic],
) -> list[_Candidate]: ) -> list[_Candidate]:
nodes_by_id = {node.id: node for node in node_records} nodes_by_id = {node.id: node for node in node_records}
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list) adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
@@ -476,6 +488,7 @@ def _attraction_candidates(
adjacency[edge.target].append(edge) adjacency[edge.target].append(edge)
candidates: dict[tuple[str, str], _Candidate] = {} candidates: dict[tuple[str, str], _Candidate] = {}
depth_limit_diagnostics: set[tuple[str, str]] = set()
for definition_order, definition in enabled_definitions: for definition_order, definition in enabled_definitions:
seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set()) seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set())
if not seed_node_ids: if not seed_node_ids:
@@ -486,6 +499,27 @@ def _attraction_candidates(
while queue: while queue:
node_id, depth = queue.popleft() node_id, depth = queue.popleft()
if depth >= rule.depth: 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 continue
for edge in adjacency.get(node_id, []): for edge in adjacency.get(node_id, []):
if not _edge_matches_attraction_rule(edge, rule): 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)) 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, ...]: def _node_records(nodes: Iterable[Mapping[str, Any]]) -> tuple[_NodeRecord, ...]:
records: list[_NodeRecord] = [] records: list[_NodeRecord] = []
for order, element in enumerate(nodes): for order, element in enumerate(nodes):

View File

@@ -417,6 +417,11 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "zoneForData" in page assert "zoneForData" in page
assert "defaultZoneDefinitions" in page assert "defaultZoneDefinitions" in page
assert "resolveZoneInstances" 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 'normalize: "deploymentEnvironment"' in page
assert "zoneBoundsForNodes" in page assert "zoneBoundsForNodes" in page
assert "renderZoneOverlay" in page assert "renderZoneOverlay" in page

View File

@@ -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].edge_id == "edge.prod-test"
assert resolution.boundary_edges[0].source_zone_id == "prod" assert resolution.boundary_edges[0].source_zone_id == "prod"
assert resolution.boundary_edges[0].target_zone_id == "test" 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: 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 "far" not in resolution.node_assignments
assert resolution.zone_by_id("prod").internal_edge_ids == ("edge.seed-near",) 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 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: 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["zones"][0]["id"] == "prod"
assert serialized["node_assignments"]["svc"]["zone_id"] == "prod" assert serialized["node_assignments"]["svc"]["zone_id"] == "prod"
assert serialized["node_assignments"]["svc"]["reason"] == "seed" 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}

View File

@@ -127,7 +127,7 @@ preserving edge evidence in zone details.
```task ```task
id: RAIL-FAB-WP-0022-T04 id: RAIL-FAB-WP-0022-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9" 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 Expected result: zone detail panels show scoped diagnostics, and tests verify
that diagnostics are generated by the resolver rather than ad hoc UI checks. 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 5: Persist Zone View Settings In Profiles
```task ```task