From 1ad432270b86539334d559e293b7023cc613323c Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 25 May 2026 00:09:18 +0200 Subject: [PATCH] feat: show zone resolver diagnostics --- docs/graph-explorer-contract.md | 6 +++ railiance_fabric/graph_explorer_ui.py | 36 ++++++++++++- railiance_fabric/zone_view.py | 52 +++++++++++++++++++ tests/test_graph_explorer.py | 5 ++ tests/test_zone_view.py | 22 ++++++++ ...P-0022-zone-entity-visualization-engine.md | 8 ++- 6 files changed, 126 insertions(+), 3 deletions(-) diff --git a/docs/graph-explorer-contract.md b/docs/graph-explorer-contract.md index 960bee5..d6057a6 100644 --- a/docs/graph-explorer-contract.md +++ b/docs/graph-explorer-contract.md @@ -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 diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index e032c22..af651f4 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -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]) => `
  • ${escapeHtml(key)} ${escapeHtml(value)}
  • `) + .map(([key, value]) => `
  • ${escapeHtml(key)} ${escapeHtml(value)}
  • `) .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) => `
  • diff --git a/railiance_fabric/zone_view.py b/railiance_fabric/zone_view.py index ff71781..a1fec95 100644 --- a/railiance_fabric/zone_view.py +++ b/railiance_fabric/zone_view.py @@ -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): diff --git a/tests/test_graph_explorer.py b/tests/test_graph_explorer.py index 71a68a1..fcd521d 100644 --- a/tests/test_graph_explorer.py +++ b/tests/test_graph_explorer.py @@ -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 diff --git a/tests/test_zone_view.py b/tests/test_zone_view.py index e659147..bdda5eb 100644 --- a/tests/test_zone_view.py +++ b/tests/test_zone_view.py @@ -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} diff --git a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md index 9c7f2b7..c131404 100644 --- a/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md +++ b/workplans/RAIL-FAB-WP-0022-zone-entity-visualization-engine.md @@ -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