Compare commits

...

2 Commits

Author SHA1 Message Date
1ad432270b feat: show zone resolver diagnostics 2026-05-25 00:09:18 +02:00
f1cd74dc7b feat: resolve graph explorer zones from definitions 2026-05-25 00:00:28 +02:00
6 changed files with 377 additions and 55 deletions

View File

@@ -156,7 +156,9 @@ reach it here?" without mutating the underlying Fabric responsibility boundary.
Zone boundary overlays are a visual layer over the graph canvas. They should be
computed from visible node positions after layout/filtering rather than modeled
as graph parent nodes. The default boundary grouping is `deploymentEnvironment`:
as graph parent nodes. The overlay should be backed by explicit zone definitions
and a resolver so visible nodes are assigned to at most one rendered zone. The
default boundary grouping is `deploymentEnvironment`:
| Overlay label | Matching nodes |
|---------------|----------------|
@@ -180,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

View File

@@ -988,10 +988,257 @@ def graph_explorer_page() -> str:
.split("")
.reduce((total, character) => (total * 31 + character.charCodeAt(0)) >>> 0, 0);
const normalizeDeploymentZoneValue = (value) => {
const raw = String(value || "").trim();
const normalized = raw.toLowerCase();
if (!normalized || normalized === "all") return "";
if (normalized === "dev") return "dev-tegwick";
if (normalized === "test" || normalized === "staging") return "test";
if (normalized === "prod") return "prod";
return raw;
};
const defaultZoneDefinitions = {
deploymentEnvironment: [
{
id: "deploymentEnvironment:dev-tegwick",
label: "dev-tegwick",
field: "deploymentEnvironment",
value: "dev-tegwick",
rank: 0,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "dev-tegwick",
normalize: "deploymentEnvironment",
},
presentation: {height: 10, color: "#0f766e", fill: "rgba(15, 118, 110, .07)"},
},
{
id: "deploymentEnvironment:test",
label: "test",
field: "deploymentEnvironment",
value: "test",
rank: 1,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "test",
normalize: "deploymentEnvironment",
},
presentation: {height: 20, color: "#2563eb", fill: "rgba(37, 99, 235, .07)"},
},
{
id: "deploymentEnvironment:prod",
label: "prod",
field: "deploymentEnvironment",
value: "prod",
rank: 2,
membership: {
field: "deploymentEnvironment",
op: "equals",
value: "prod",
normalize: "deploymentEnvironment",
},
presentation: {height: 30, color: "#be123c", fill: "rgba(190, 18, 60, .07)"},
},
],
};
const zoneElementData = (element) => {
if (!element) return {};
if (typeof element.data === "function") return element.data();
return element.data && typeof element.data === "object" ? element.data : element;
};
const zoneFieldValue = (data, field) => {
if (!field) return "";
let current = data || {};
String(field).split(".").forEach((part) => {
current = current && typeof current === "object" ? current[part] : undefined;
});
return current;
};
const zoneRuleValue = (data, rule) => {
const value = zoneFieldValue(data, rule.field);
if (rule.normalize === "deploymentEnvironment") return normalizeDeploymentZoneValue(value);
return hasValue(value) ? String(value).trim() : "";
};
const zoneRuleMatches = (data, rule, emptyMatches = false) => {
if (!rule || !Object.keys(rule).length) return emptyMatches;
if (Array.isArray(rule.all)) {
return rule.all.every((child) => zoneRuleMatches(data, child));
}
if (Array.isArray(rule.any)) {
return rule.any.some((child) => zoneRuleMatches(data, child));
}
if (Array.isArray(rule.rules)) {
return rule.rules.every((child) => zoneRuleMatches(data, child));
}
const actual = zoneRuleValue(data, rule);
if (rule.op === "exists") return hasValue(actual);
if (rule.op === "in") {
const expected = Array.isArray(rule.value) ? rule.value : [rule.value];
return expected.map((value) => String(value)).includes(String(actual));
}
return String(actual) === String(rule.value ?? "");
};
const zoneDescriptorFromDefinition = (definition) => ({
field: definition.field,
id: definition.id,
label: definition.label || definition.id,
value: definition.value || definition.label || definition.id,
rank: definition.rank,
presentation: definition.presentation || {},
membership: definition.membership || {},
diagnostics: [],
});
const accessZoneDefinition = (value) => {
const label = String(value || "").trim();
if (!label) return null;
return {
id: `accessZone:${label}`,
label,
field: "accessZone",
value: label,
rank: 10 + hashString(label) % 20,
membership: {field: "accessZone", op: "equals", value: label},
presentation: {},
};
};
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") {
const definition = accessZoneDefinition(zoneFieldValue(data, "accessZone"));
return definition ? [definition] : [];
}
return [];
};
const zoneDefinitionsForElements = (elements, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") return defaultZoneDefinitions.deploymentEnvironment;
if (grouping === "accessZone") {
const values = new Set();
elements.forEach((element) => {
const value = String(zoneFieldValue(zoneElementData(element), "accessZone") || "").trim();
if (value) values.add(value);
});
return Array.from(values)
.sort((left, right) => left.localeCompare(right))
.map(accessZoneDefinition)
.filter(Boolean);
}
return [];
};
const chooseZoneCandidate = (candidates) => candidates
.slice()
.sort((left, right) => {
const leftHeight = Number(left.definition.presentation?.height) || 0;
const rightHeight = Number(right.definition.presentation?.height) || 0;
return rightHeight - leftHeight || left.index - right.index || left.definition.id.localeCompare(right.definition.id);
})[0];
const resolveZoneInstances = (elements, definitions) => {
const zones = new Map();
definitions.forEach((definition, index) => {
zones.set(definition.id, {
...zoneDescriptorFromDefinition(definition),
definition,
definitionIndex: index,
nodes: [],
elements: [],
nodeIds: new Set(),
elementIds: new Set(),
diagnostics: [],
});
});
const assignments = new Map();
const addElementToZone = (zoneId, element) => {
const zone = zones.get(zoneId);
if (!zone) return;
const elementId = element.id ? element.id() : zoneElementData(element).id;
if (elementId && zone.elementIds.has(elementId)) return;
if (elementId) zone.elementIds.add(elementId);
zone.elements.push(element);
};
elements.filter((element) => element.isNode && element.isNode()).forEach((node) => {
const data = zoneElementData(node);
const candidates = definitions
.map((definition, index) => ({definition, index}))
.filter((candidate) => zoneRuleMatches(data, candidate.definition.membership));
if (!candidates.length) return;
if (candidates.length > 1) {
const diagnostic = {
severity: "warning",
code: zoneDiagnosticCodes.nodeSeededByMultipleZones,
message: `${elementLabel(node)} matched multiple zone definitions.`,
};
candidates.forEach((candidate) => zones.get(candidate.definition.id)?.diagnostics.push(diagnostic));
}
const chosen = chooseZoneCandidate(candidates).definition;
const zone = zones.get(chosen.id);
if (!zone) return;
assignments.set(node.id(), chosen.id);
zone.nodeIds.add(node.id());
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(
definitions
.filter((definition) => zoneRuleMatches(data, definition.membership))
.map((definition) => definition.id)
);
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));
});
return zones;
};
const zoneStyle = (zone) =>
zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
zone.presentation?.color
? {color: zone.presentation.color, fill: zone.presentation.fill || zonePalette[zone.id]?.fill || "rgba(37, 99, 235, .07)"}
: zonePalette[zone.id] || fallbackZonePalette[hashString(zone.id) % fallbackZonePalette.length];
const zoneRank = (zone) => {
if (Number.isFinite(zone.rank)) return zone.rank;
if (zone.id === "deploymentEnvironment:dev-tegwick") return 0;
if (zone.id === "deploymentEnvironment:test") return 1;
if (zone.id === "deploymentEnvironment:prod") return 2;
@@ -1011,36 +1258,9 @@ def graph_explorer_page() -> str:
};
const zoneForData = (data, grouping = activeZoneGrouping) => {
if (grouping === "deploymentEnvironment") {
const value = String(data.deploymentEnvironment || "").trim();
if (!value) return null;
const normalized = value.toLowerCase();
if (normalized === "all") return null;
const label = normalized === "dev"
? "dev-tegwick"
: normalized === "test" || normalized === "staging"
? "test"
: normalized === "prod"
? normalized
: value;
return {
field: "deploymentEnvironment",
id: `deploymentEnvironment:${label}`,
label,
value,
};
}
if (grouping === "accessZone") {
const value = String(data.accessZone || "").trim();
if (!value) return null;
return {
field: "accessZone",
id: `accessZone:${value}`,
label: value,
value,
};
}
return null;
const definition = zoneDefinitionsForData(data, grouping)
.find((candidate) => zoneRuleMatches(data, candidate.membership));
return definition ? zoneDescriptorFromDefinition(definition) : null;
};
const renderedNodeBox = (node) => {
@@ -1091,30 +1311,22 @@ def graph_explorer_page() -> str:
};
const collectZoneSummaries = () => {
const groups = new Map();
if (!cy) return groups;
cy.elements().filter((element) => element.style("display") !== "none").forEach((element) => {
const zone = zoneForData(element.data());
if (!zone) return;
if (!groups.has(zone.id)) {
groups.set(zone.id, {...zone, nodes: [], elements: []});
}
const group = groups.get(zone.id);
group.elements.push(element);
if (element.isNode()) group.nodes.push(element);
});
return groups;
if (!cy) return new Map();
const visibleElements = cy.elements()
.filter((element) => element.style("display") !== "none")
.toArray();
return resolveZoneInstances(
visibleElements,
zoneDefinitionsForElements(visibleElements, activeZoneGrouping)
);
};
const zoneCountsForField = (elements, field) => {
if (field !== "deploymentEnvironment" && field !== "accessZone") return groupCounts(elements, field);
const groups = new Map();
elements.forEach((element) => {
const zone = zoneForData(element.data(), field);
if (!zone) return;
groups.set(zone.label, (groups.get(zone.label) || 0) + 1);
});
return Array.from(groups.entries())
const zones = resolveZoneInstances(elements, zoneDefinitionsForElements(elements, field));
return Array.from(zones.values())
.filter((zone) => zone.elements.length > 0)
.map((zone) => [zone.label, zone.elements.length])
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
};
@@ -1136,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")],
@@ -1143,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()));
@@ -1161,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)}">

View File

@@ -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):

View File

@@ -415,6 +415,14 @@ def test_registry_serves_graph_explorer_exports(tmp_path: Path) -> None:
assert "ruleRemovalSignature" in page
assert "zoneModeFields" in page
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
assert "renderZoneDetails" 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].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}

View File

@@ -95,7 +95,7 @@ summaries, and conflict diagnostics. Covered the core behavior in
```task
id: RAIL-FAB-WP-0022-T03
status: todo
status: done
priority: high
state_hub_task_id: "c89d8d53-72a4-4590-a0e5-67b012c3550c"
```
@@ -114,11 +114,20 @@ The current operator-facing behavior should remain available:
Expected result: existing graph explorer tests continue to pass, with new tests
showing that the UI obtains zone rectangles from resolved zone instances.
Result: Refactored the graph explorer overlay to use explicit default zone
definitions and a client-side `resolveZoneInstances()` path. Deployment
environment zones now resolve through declarative membership rules with
`deploymentEnvironment` normalization for `dev` -> `dev-tegwick`,
`test`/`staging` -> `test`, `prod` -> `prod`, and ignored `all` values. Access
zone overlays are generated as dynamic definitions from visible graph evidence.
The overlay keeps the single-zone visible-node assignment behavior while
preserving edge evidence in zone details.
## Task 4: Add Zone Diagnostics To The Explorer
```task
id: RAIL-FAB-WP-0022-T04
status: todo
status: done
priority: medium
state_hub_task_id: "d140cb5b-6a35-4cb0-ab68-e39e708c08e9"
```
@@ -137,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