diff --git a/catalog/capability-types.yaml b/catalog/capability-types.yaml index 905ad98..a4e70f5 100644 --- a/catalog/capability-types.yaml +++ b/catalog/capability-types.yaml @@ -117,6 +117,7 @@ spec: default_data_classification: internal expected_interface_types: - http-api + - mcp-api - event-stream tags: [coordination, state-hub, planning] diff --git a/catalog/interface-types.yaml b/catalog/interface-types.yaml index bcf916a..9a3e0cf 100644 --- a/catalog/interface-types.yaml +++ b/catalog/interface-types.yaml @@ -25,6 +25,14 @@ spec: typical_auth_methods: [none, oidc, jwt, mtls, api_key] versioning: URL path, static asset version, and documented user-facing workflow compatibility. + - id: mcp-api + name: MCP API + lifecycle: active + description: Model Context Protocol interface consumed by agents or agent runtimes. + category: api + typical_auth_methods: [none, oidc, jwt, mtls, api_key] + versioning: MCP protocol version, tool schema version, and documented capability compatibility. + - id: oidc-discovery name: OIDC discovery lifecycle: active diff --git a/docs/type-catalog.md b/docs/type-catalog.md index 859f985..3552062 100644 --- a/docs/type-catalog.md +++ b/docs/type-catalog.md @@ -36,6 +36,7 @@ Machine-readable catalog files: |------|-----------|----------|--------------| | `http-api` | active | api | `none`, `oidc`, `jwt`, `mtls`, `api_key` | | `web-ui` | active | ui | `none`, `oidc`, `jwt`, `mtls`, `api_key` | +| `mcp-api` | active | api | `none`, `oidc`, `jwt`, `mtls`, `api_key` | | `oidc-discovery` | active | identity | `none` | | `kubernetes-secret` | active | kubernetes | `kubernetes_service_account` | | `kubernetes-crd` | active | kubernetes | `kubernetes_service_account` | diff --git a/fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml b/fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml new file mode 100644 index 0000000..6379051 --- /dev/null +++ b/fabric/discovery/snapshots/2026-05-24-deployment-zone-inventory.yaml @@ -0,0 +1,404 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: DeploymentZoneInventory +generated_at: "2026-05-24T00:00:00+02:00" +source: + repo: railiance-fabric + workplan: RAIL-FAB-WP-0020 + method: source-search-and-declared-surfaces +scope: + note: > + This inventory captures deployment-zone overlay evidence. It does not + define fabric membership, port ownership, live health, or access policy. + deployment_environments: + - id: dev + scenario: bernd-laptop + intended_reachability: private operator workstation + - id: test + scenario: coulombcore + intended_reachability: shared collaborator and early-access test stage + - id: prod + scenario: railiance01 + intended_reachability: production stage, currently alpha-accessible to developers +surfaces: + - id: dev.bernd-laptop.railiance-fabric.registry-api + name: Railiance Fabric registry HTTP API + repo: railiance-fabric + service_id: railiance-fabric.registry + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8765 + host: 127.0.0.1 + port: 8765 + protocol: http + evidence: + - path: fabric/interfaces/railiance-fabric-registry-http-api.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.railiance-fabric.graph-explorer + name: Railiance Fabric graph explorer UI + repo: railiance-fabric + service_id: railiance-fabric.registry + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8765/ui/graph-explorer + host: 127.0.0.1 + port: 8765 + protocol: http + path: /ui/graph-explorer + evidence: + - path: fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.state-hub.api + name: State Hub HTTP API + repo: the-custodian + service_id: the-custodian.state-hub + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8000 + host: 127.0.0.1 + port: 8000 + protocol: http + evidence: + - path: fabric/interfaces/the-custodian-state-hub-http-api.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.state-hub.mcp + name: State Hub MCP API + repo: the-custodian + service_id: the-custodian.state-hub + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8001 + host: 127.0.0.1 + port: 8001 + protocol: http + evidence: + - path: fabric/interfaces/the-custodian-state-hub-mcp-api.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.state-hub.dashboard + name: State Hub dashboard + repo: the-custodian + service_id: the-custodian.state-hub + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:3000 + host: 127.0.0.1 + port: 3000 + protocol: http + evidence: + - path: fabric/interfaces/the-custodian-state-hub-dashboard.yaml + kind: fabric-interface-declaration + - id: dev.bernd-laptop.net-kingdom.control-surface + name: NetKingdom control surface + repo: net-kingdom + service_id: net-kingdom.iam-profile + deployment_environment: dev + deployment_scenario: bernd-laptop + access_zone: private-dev + exposure_class: local-only + routing_authority: local-loopback-binding + policy_authority: local-loopback-binding + route_evidence: + route: http://127.0.0.1:8876 + host: 127.0.0.1 + port: 8876 + protocol: http + evidence: + - path: fabric/interfaces/net-kingdom-control-surface-ui.yaml + kind: fabric-interface-declaration + - path: ../net-kingdom/sso-mfa/k8s/keycape/README.md + kind: source-search-hit + note: local OIDC callback lists localhost port 8876 + - id: test.coulombcore.state-hub.http-tunnel + name: State Hub HTTP API tunnel to coulombcore + repo: railiance-infra + service_id: the-custodian.state-hub + deployment_environment: test + deployment_scenario: coulombcore + access_zone: collaborator-test + exposure_class: collaborator-test + routing_authority: ops-bridge + policy_authority: ops-bridge-ssh + route_evidence: + route: http://127.0.0.1:18000 + host: 127.0.0.1 + port: 18000 + protocol: http + tunnel_target: coulombcore + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "127" + kind: source-search-hit + - id: test.coulombcore.state-hub.mcp-tunnel + name: State Hub MCP tunnel to coulombcore + repo: railiance-infra + service_id: the-custodian.state-hub + deployment_environment: test + deployment_scenario: coulombcore + access_zone: collaborator-test + exposure_class: collaborator-test + routing_authority: ops-bridge + policy_authority: ops-bridge-ssh + route_evidence: + route: http://127.0.0.1:18001 + host: 127.0.0.1 + port: 18001 + protocol: http + tunnel_target: coulombcore + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "128" + kind: source-search-hit + - id: test.coulombcore.k3s-api-tunnel + name: k3s API tunnel to coulombcore + repo: railiance-infra + deployment_environment: test + deployment_scenario: coulombcore + access_zone: collaborator-test + exposure_class: collaborator-test + routing_authority: ops-bridge + policy_authority: ops-bridge-ssh + route_evidence: + route: https://127.0.0.1:16443 + host: 127.0.0.1 + port: 16443 + protocol: https + tunnel_target: coulombcore + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "129" + kind: source-search-hit + - path: ../railiance-cluster/SCOPE.md + lines: "127" + kind: source-search-hit + note: cluster scope states it runs on COULOMBCORE + - id: prod.railiance01.gitea + name: Gitea ingress + repo: railiance-apps + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: null + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://gitea.coulomb.social + hostname: gitea.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: access zone and policy authority require operator review + evidence: + - path: ../railiance-apps/manifests/gitea-ingress.yaml + lines: "2,12,14,16,27" + kind: kubernetes-ingress + - path: ../railiance-apps/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md + lines: "612,613" + kind: source-search-hit + note: places Gitea before vergabe-teilnahme on railiance01 + - id: prod.railiance01.vergabe-teilnahme + name: Vergabe Teilnahme ingress + repo: railiance-apps + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: null + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://vergabe-teilnahme.whywhynot.de + hostname: vergabe-teilnahme.whywhynot.de + port: 443 + protocol: https + review: + status: candidate + note: production public classification is inferred from ingress host and workplan + evidence: + - path: ../railiance-apps/manifests/vergabe-teilnahme-ingress.yaml + lines: "2,11,13,15,26" + kind: kubernetes-ingress + - path: ../railiance-apps/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md + lines: "22,40,68,69,163,612,613" + kind: source-search-hit + - id: prod.railiance01.authelia + name: Authelia ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: authelia + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://auth.coulomb.social + hostname: auth.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: railiance01 attribution comes from NetKingdom deployment workplan + evidence: + - path: ../net-kingdom/sso-mfa/k8s/authelia/ingress.yaml + lines: "13,22,24,26,38" + kind: kubernetes-ingress + - path: ../net-kingdom/workplans/NK-WP-0003-keycape-privacyidea-cluster-deployment.md + lines: "29,47,88,101" + kind: source-search-hit + - id: prod.railiance01.keycape + name: Keycape ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: traefik-middleware + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://kc.coulomb.social + hostname: kc.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: middleware is present, but intended audience still needs operator review + evidence: + - path: ../net-kingdom/sso-mfa/k8s/keycape/ingress.yaml + lines: "13,22,23,27,29,41" + kind: kubernetes-ingress + - path: ../net-kingdom/sso-mfa/k8s/keycape/middleware.yaml + lines: "9,24" + kind: traefik-middleware + - id: prod.railiance01.privacyidea + name: privacyIDEA ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-admin + exposure_class: production-admin + routing_authority: traefik + policy_authority: traefik-middleware + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://pink.coulomb.social + hostname: pink.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: admin classification inferred from privacyIDEA role and middleware + evidence: + - path: ../net-kingdom/sso-mfa/k8s/privacyidea/ingress.yaml + lines: "25,34,36,38,40,52,60,69,71,75,77,89" + kind: kubernetes-ingress + - path: ../net-kingdom/sso-mfa/k8s/privacyidea/middleware.yaml + lines: "19,41" + kind: traefik-middleware + - id: prod.railiance01.privacyidea-account + name: privacyIDEA account self-service ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-public + exposure_class: production-public + routing_authority: traefik + policy_authority: traefik-middleware + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://pink-account.coulomb.social + hostname: pink-account.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: self-service classification inferred from host name and middleware + evidence: + - path: ../net-kingdom/sso-mfa/k8s/privacyidea/ingress.yaml + lines: "94,103,104,106,108,120" + kind: kubernetes-ingress + - id: prod.railiance01.lldap + name: LLDAP ingress + repo: net-kingdom + deployment_environment: prod + deployment_scenario: railiance01 + access_zone: production-admin + exposure_class: production-admin + routing_authority: traefik + policy_authority: traefik-admin-allowlist + tls_authority: cert-manager:letsencrypt-prod + route_evidence: + route: https://lldap.coulomb.social + hostname: lldap.coulomb.social + port: 443 + protocol: https + review: + status: candidate + note: admin allowlist middleware indicates intended restricted access + evidence: + - path: ../net-kingdom/sso-mfa/k8s/lldap/ingress.yaml + lines: "12,21,22,24,26,38" + kind: kubernetes-ingress + - path: ../net-kingdom/sso-mfa/k8s/lldap/middleware.yaml + lines: "11" + kind: traefik-middleware +ambiguities: + - id: railiance01-coulombcore-ip-conflict + severity: high + summary: Source documents disagree on which host owns 92.205.130.254. + evidence: + - path: ../railiance-apps/workplans/railiance-apps-WP-0002-vergabe-teilnahme-on-railiance01.md + lines: "22,163" + note: says railiance01 and Traefik LoadBalancer use 92.205.130.254 + - path: ../railiance-infra/SCOPE.md + lines: "126" + note: says COULOMBCORE is 92.205.130.254 and Railiance01 is 92.205.62.239 + next: reconcile host inventory before treating IP evidence as authoritative + - id: prod-access-zone-review + severity: medium + summary: Production access zones are candidate classifications. + evidence: + - path: ../railiance-apps/manifests + note: app ingress manifests show routing and TLS but not business audience + - path: ../net-kingdom/sso-mfa/k8s + note: middleware and network policy hint at access intent but do not replace operator review + next: confirm each production host as public, admin, or early-access + - id: test-reachability-is-tunneled + severity: medium + summary: Current coulombcore routes are ops-bridge tunnel evidence, not public ingress evidence. + evidence: + - path: ../railiance-infra/docs/deploy-stack.md + lines: "127,128,129" + note: state-hub and k3s API access are tunnel commands + next: add executable test-stage ingress/service discovery when coulombcore manifests exist +missing_policy_authority: + - surface_id: prod.railiance01.gitea + reason: route and TLS are discovered, but access policy authority is not evident in the ingress artifact + - surface_id: prod.railiance01.vergabe-teilnahme + reason: route and TLS are discovered, but access policy authority is not evident in the ingress artifact diff --git a/fabric/interfaces/net-kingdom-control-surface-ui.yaml b/fabric/interfaces/net-kingdom-control-surface-ui.yaml new file mode 100644 index 0000000..92bfa66 --- /dev/null +++ b/fabric/interfaces/net-kingdom-control-surface-ui.yaml @@ -0,0 +1,35 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: InterfaceDeclaration +metadata: + id: net-kingdom.control-surface.ui + name: NetKingdom Control Surface + owner: net-kingdom + repo: net-kingdom + domain: railiance +spec: + lifecycle: active + environments: [dev] + description: Local NetKingdom control surface on the operator workstation. + interface_type: web-ui + version: v1 + service_id: net-kingdom.iam-profile + capability_ids: + - net-kingdom.iam-profile.issuer + endpoint: + url: http://127.0.0.1:8876 + notes: Local workstation endpoint after moving away from the Fabric registry port. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: net-kingdom-local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8876 + protocol: tcp + route: http://127.0.0.1:8876 + auth: + method: none + data_classification: internal diff --git a/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml b/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml index 76130e5..a97a054 100644 --- a/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml +++ b/fabric/interfaces/railiance-fabric-registry-graph-explorer-ui.yaml @@ -23,6 +23,18 @@ spec: endpoint: url: http://127.0.0.1:8765/ui/graph-explorer notes: Local workstation UI when the registry service is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: railiance-fabric-registry + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8765 + protocol: tcp + route: http://127.0.0.1:8765/ui/graph-explorer auth: method: none data_classification: internal diff --git a/fabric/interfaces/railiance-fabric-registry-http-api.yaml b/fabric/interfaces/railiance-fabric-registry-http-api.yaml index 6e38b20..bb69ec7 100644 --- a/fabric/interfaces/railiance-fabric-registry-http-api.yaml +++ b/fabric/interfaces/railiance-fabric-registry-http-api.yaml @@ -23,6 +23,18 @@ spec: endpoint: url: http://127.0.0.1:8765 notes: Local workstation endpoint when the registry service is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8765 + protocol: tcp + route: http://127.0.0.1:8765 auth: method: none data_classification: internal diff --git a/fabric/interfaces/the-custodian-state-hub-dashboard.yaml b/fabric/interfaces/the-custodian-state-hub-dashboard.yaml new file mode 100644 index 0000000..9435f3f --- /dev/null +++ b/fabric/interfaces/the-custodian-state-hub-dashboard.yaml @@ -0,0 +1,35 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: InterfaceDeclaration +metadata: + id: the-custodian.state-hub.dashboard + name: State Hub Dashboard + owner: the-custodian + repo: the-custodian + domain: custodian +spec: + lifecycle: active + environments: [dev] + description: Local browser dashboard for State Hub coordination views. + interface_type: web-ui + version: v1 + service_id: the-custodian.state-hub + capability_ids: + - the-custodian.state-hub.coordination + endpoint: + url: http://127.0.0.1:3000 + notes: Local workstation dashboard endpoint when the State Hub frontend is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: vite-dev-server + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 3000 + protocol: tcp + route: http://127.0.0.1:3000 + auth: + method: none + data_classification: internal diff --git a/fabric/interfaces/the-custodian-state-hub-http-api.yaml b/fabric/interfaces/the-custodian-state-hub-http-api.yaml index fbcec5f..6458059 100644 --- a/fabric/interfaces/the-custodian-state-hub-http-api.yaml +++ b/fabric/interfaces/the-custodian-state-hub-http-api.yaml @@ -15,6 +15,21 @@ spec: service_id: the-custodian.state-hub capability_ids: - the-custodian.state-hub.coordination + endpoint: + url: http://127.0.0.1:8000 + notes: Local workstation State Hub REST API. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: state-hub-local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8000 + protocol: tcp + route: http://127.0.0.1:8000 auth: method: none data_classification: internal diff --git a/fabric/interfaces/the-custodian-state-hub-mcp-api.yaml b/fabric/interfaces/the-custodian-state-hub-mcp-api.yaml new file mode 100644 index 0000000..a1ba66d --- /dev/null +++ b/fabric/interfaces/the-custodian-state-hub-mcp-api.yaml @@ -0,0 +1,35 @@ +apiVersion: railiance.fabric/v1alpha1 +kind: InterfaceDeclaration +metadata: + id: the-custodian.state-hub.mcp-api + name: State Hub MCP API + owner: the-custodian + repo: the-custodian + domain: custodian +spec: + lifecycle: active + environments: [dev] + description: Local MCP surface for State Hub coordination tools. + interface_type: mcp-api + version: v1 + service_id: the-custodian.state-hub + capability_ids: + - the-custodian.state-hub.coordination + endpoint: + url: http://127.0.0.1:8001 + notes: Local workstation MCP endpoint when State Hub is running. + deployment_overlay: + deployment_environment: dev + deployment_scenario: bernd-laptop + routing_authority: state-hub-local-process + access_zone: private-dev + policy_authority: local-loopback-binding + exposure_class: local-only + route_evidence: + host: 127.0.0.1 + port: 8001 + protocol: tcp + route: http://127.0.0.1:8001 + auth: + method: none + data_classification: internal diff --git a/fabric/services/net-kingdom-iam-profile.yaml b/fabric/services/net-kingdom-iam-profile.yaml index 33cac2f..452d12b 100644 --- a/fabric/services/net-kingdom-iam-profile.yaml +++ b/fabric/services/net-kingdom-iam-profile.yaml @@ -15,3 +15,4 @@ spec: - net-kingdom.iam-profile.issuer exposes_interfaces: - net-kingdom.iam-profile.oidc-discovery + - net-kingdom.control-surface.ui diff --git a/fabric/services/the-custodian-state-hub.yaml b/fabric/services/the-custodian-state-hub.yaml index 9044854..688fb70 100644 --- a/fabric/services/the-custodian-state-hub.yaml +++ b/fabric/services/the-custodian-state-hub.yaml @@ -15,3 +15,5 @@ spec: - the-custodian.state-hub.coordination exposes_interfaces: - the-custodian.state-hub.http-api + - the-custodian.state-hub.mcp-api + - the-custodian.state-hub.dashboard diff --git a/railiance_fabric/accountability_roots.py b/railiance_fabric/accountability_roots.py index 4a7a9b6..5b03c72 100644 --- a/railiance_fabric/accountability_roots.py +++ b/railiance_fabric/accountability_roots.py @@ -11,6 +11,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from .deployment_overlay import normalize_deployment_overlay from .discovery import normalize_identity_part, short_fingerprint from .loader import load_yaml, repo_root from .schema_validation import draft202012_validator @@ -619,7 +620,7 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ or declared_slug or Path(str(source.get("path") or "")).name ) - return { + candidate = { "identity_type": "Repository", "label": identity_slug, "graph_id": identity_slug, @@ -635,6 +636,10 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ }, "confidence": 0.9 if evidence_type == "repository_checkout" else 0.85, } + overlay = normalize_deployment_overlay(source, attributes) + if overlay: + candidate["deployment_overlay"] = overlay + return candidate if evidence_type in {"deployment_automation", "infrastructure_manifest"}: path = str(source.get("path") or "") return { @@ -665,7 +670,7 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ } if evidence_type == "endpoint_contract": path = str(source.get("path") or "") - return { + candidate = { "identity_type": "Endpoint", "label": Path(path).name or "endpoint-contract", "graph_id": path, @@ -677,6 +682,10 @@ def _identity_from_evidence(root: dict[str, Any], item: dict[str, Any]) -> dict[ "attributes": {**attributes, "source_evidence_type": evidence_type}, "confidence": 0.75, } + overlay = normalize_deployment_overlay(source, attributes) + if overlay: + candidate["deployment_overlay"] = overlay + return candidate if evidence_type == "host_path_match": path = str(source.get("path") or "") return { @@ -901,6 +910,7 @@ def _add_identity_candidate( evidence_ids: list[str], aliases: list[str], attributes: dict[str, Any], + deployment_overlay: dict[str, Any] | None = None, confidence: float, ) -> None: normalized_type = normalize_identity_part(identity_type) @@ -924,6 +934,9 @@ def _add_identity_candidate( incoming["subfabric_id"] = subfabric_id if owner_actor_id: incoming["owner_actor_id"] = owner_actor_id + overlay = normalize_deployment_overlay(deployment_overlay or {}, attributes) + if overlay: + incoming["deployment_overlay"] = overlay existing = candidates.get(stable_key) if existing is None: @@ -933,6 +946,11 @@ def _add_identity_candidate( existing["aliases"] = _unique_strings([*existing.get("aliases", []), *incoming["aliases"]]) existing["evidence_ids"] = _unique_strings([*existing.get("evidence_ids", []), *incoming["evidence_ids"]]) existing["attributes"] = {**existing.get("attributes", {}), **incoming["attributes"]} + if incoming.get("deployment_overlay"): + existing["deployment_overlay"] = normalize_deployment_overlay( + existing.get("deployment_overlay") if isinstance(existing.get("deployment_overlay"), dict) else {}, + incoming["deployment_overlay"], + ) if incoming.get("owner_actor_id") and existing.get("owner_actor_id") and incoming["owner_actor_id"] != existing["owner_actor_id"]: existing["attributes"]["ambiguous_owner_actor_ids"] = _unique_strings( [existing["owner_actor_id"], incoming["owner_actor_id"], *existing["attributes"].get("ambiguous_owner_actor_ids", [])] @@ -973,6 +991,7 @@ def _candidate_graph(candidates: list[dict[str, Any]], manifest: dict[str, Any]) "fabric_id": candidate.get("fabric_id", ""), "subfabric_id": candidate.get("subfabric_id", ""), "owner_actor_id": candidate.get("owner_actor_id", ""), + "deployment_overlay": candidate.get("deployment_overlay", {}), } for candidate in sorted(candidates, key=lambda item: item["stable_key"]) ] diff --git a/railiance_fabric/deployment_overlay.py b/railiance_fabric/deployment_overlay.py new file mode 100644 index 0000000..e317336 --- /dev/null +++ b/railiance_fabric/deployment_overlay.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from typing import Any + + +OVERLAY_FIELDS = ( + "deployment_environment", + "deployment_scenario", + "routing_authority", + "access_zone", + "policy_authority", + "exposure_class", +) + +ROUTE_EVIDENCE_FIELDS = ( + "host", + "hostname", + "port", + "protocol", + "route", + "route_url", + "path", + "scheme", +) + +_ALIASES = { + "deployment_environment": ("deployment_environment", "environment"), + "deployment_scenario": ("deployment_scenario", "deployment_scenario_id", "scenario"), + "routing_authority": ("routing_authority",), + "access_zone": ("access_zone",), + "policy_authority": ("policy_authority",), + "exposure_class": ("exposure_class", "exposure"), +} + + +def normalize_deployment_overlay(*sources: object) -> dict[str, Any]: + """Collect deployment-zone overlay fields without changing fabric membership.""" + + overlay: dict[str, Any] = {} + route_evidence: dict[str, Any] = {} + + for source in sources: + if not isinstance(source, dict): + continue + nested = source.get("deployment_overlay") + if isinstance(nested, dict): + _merge_overlay_source(overlay, route_evidence, nested) + _merge_overlay_source(overlay, route_evidence, source) + + if route_evidence: + overlay["route_evidence"] = route_evidence + return {key: value for key, value in overlay.items() if _has_value(value)} + + +def graph_explorer_overlay_fields(overlay: dict[str, Any]) -> dict[str, Any]: + route = overlay.get("route_evidence") if isinstance(overlay.get("route_evidence"), dict) else {} + return { + "deploymentOverlay": overlay, + "deploymentEnvironment": str(overlay.get("deployment_environment") or ""), + "deploymentScenario": str(overlay.get("deployment_scenario") or ""), + "routingAuthority": str(overlay.get("routing_authority") or ""), + "accessZone": str(overlay.get("access_zone") or ""), + "policyAuthority": str(overlay.get("policy_authority") or ""), + "exposureClass": str(overlay.get("exposure_class") or ""), + "routeHost": str(route.get("host") or ""), + "routeHostname": str(route.get("hostname") or ""), + "routePort": route.get("port") if route.get("port") not in (None, "") else "", + "routeProtocol": str(route.get("protocol") or ""), + "route": str(route.get("route") or route.get("route_url") or ""), + } + + +def _merge_overlay_source(overlay: dict[str, Any], route_evidence: dict[str, Any], source: dict[str, Any]) -> None: + for field in OVERLAY_FIELDS: + if field in overlay: + continue + value = _first_value(source, _ALIASES[field]) + if _has_value(value): + overlay[field] = value + + nested_route = source.get("route_evidence") + if isinstance(nested_route, dict): + for field in ROUTE_EVIDENCE_FIELDS: + value = nested_route.get(field) + if _has_value(value) and field not in route_evidence: + route_evidence[field] = value + + for field in ROUTE_EVIDENCE_FIELDS: + value = source.get(field) + if _has_value(value) and field not in route_evidence: + route_evidence[field] = value + + +def _first_value(source: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + value = source.get(key) + if _has_value(value): + return value + return "" + + +def _has_value(value: Any) -> bool: + if isinstance(value, dict): + return any(_has_value(item) for item in value.values()) + if isinstance(value, list): + return any(_has_value(item) for item in value) + return value not in (None, "") diff --git a/railiance_fabric/financial.py b/railiance_fabric/financial.py index 5f08b16..2979e4e 100644 --- a/railiance_fabric/financial.py +++ b/railiance_fabric/financial.py @@ -3,6 +3,8 @@ from __future__ import annotations import json from typing import Any +from .deployment_overlay import normalize_deployment_overlay + FINANCIAL_API_VERSION = "railiance.fabric/v1alpha2" FINANCIAL_SCHEMA_VERSION = "financial-fabric-v1" @@ -19,7 +21,7 @@ RELATIONSHIP_CATEGORIES = { "evidence", } FABRIC_KINDS = {"Fabric", "Subfabric"} -ENVIRONMENT_LABELS = {"local", "dev", "staging", "prod", "lab", "all"} +ENVIRONMENT_LABELS = {"local", "dev", "test", "staging", "prod", "lab", "all"} def is_financial_graph_export(graph: dict[str, Any]) -> bool: @@ -125,6 +127,7 @@ def financial_graph_errors(graph: dict[str, Any]) -> list[str]: _validate_containment(errors, f"nodes[{index}]", node, netkingdom_id, fabric_kinds, accepted=review_state == "accepted") _validate_ownership(errors, f"nodes[{index}]", node, actor_roles, accepted=review_state == "accepted") _validate_optional_object(errors, f"nodes[{index}].accounting", node, "accounting") + _validate_overlay(errors, f"nodes[{index}].deployment_overlay", node) edges = _indexed_items(errors, graph, "edges") for index, edge in edges: @@ -141,6 +144,7 @@ def financial_graph_errors(graph: dict[str, Any]) -> list[str]: errors.append(f"{path}.relationship_category {category!r} is not valid") _validate_evidence(errors, path, edge) _validate_optional_object(errors, f"{path}.accounting", edge, "accounting") + _validate_overlay(errors, f"{path}.deployment_overlay", edge) if edge_type == "provides_utility_to" and category != "utility": errors.append(f"{path}.relationship_category must be 'utility' for provides_utility_to edges") if category == "utility": @@ -211,6 +215,13 @@ def _materialize_node(node: dict[str, Any]) -> None: for key in ("containment", "ownership", "accounting"): if key not in node and isinstance(attrs.get(key), dict): node[key] = attrs[key] + overlay = normalize_deployment_overlay( + node.get("deployment_overlay") if isinstance(node.get("deployment_overlay"), dict) else {}, + attrs, + node.get("containment") if isinstance(node.get("containment"), dict) else {}, + ) + if overlay: + node["deployment_overlay"] = overlay node.setdefault("evidence", _legacy_evidence(node, attrs)) @@ -218,6 +229,12 @@ def _materialize_edge(edge: dict[str, Any]) -> None: attrs = edge.get("attributes") if isinstance(edge.get("attributes"), dict) else {} if "accounting" not in edge and isinstance(attrs.get("accounting"), dict): edge["accounting"] = attrs["accounting"] + overlay = normalize_deployment_overlay( + edge.get("deployment_overlay") if isinstance(edge.get("deployment_overlay"), dict) else {}, + attrs, + ) + if overlay: + edge["deployment_overlay"] = overlay edge.setdefault("relationship_category", _relationship_category(edge)) edge.setdefault("evidence", _legacy_evidence(edge, attrs)) if edge.get("relationship_category") == "utility": @@ -362,6 +379,18 @@ def _validate_optional_object(errors: list[str], path: str, item: dict[str, Any] errors.append(f"{path} must be an object") +def _validate_overlay(errors: list[str], path: str, item: dict[str, Any]) -> None: + overlay = item.get("deployment_overlay") + if overlay is None: + return + if not isinstance(overlay, dict): + errors.append(f"{path} must be an object") + return + route = overlay.get("route_evidence") + if route is not None and not isinstance(route, dict): + errors.append(f"{path}.route_evidence must be an object") + + def _required_object(errors: list[str], path: str, item: dict[str, Any], key: str) -> dict[str, Any]: value = item.get(key) if not isinstance(value, dict): diff --git a/railiance_fabric/financial_baseline.py b/railiance_fabric/financial_baseline.py index 80ae4d5..6911363 100644 --- a/railiance_fabric/financial_baseline.py +++ b/railiance_fabric/financial_baseline.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +from .deployment_overlay import normalize_deployment_overlay from .financial import ( FINANCIAL_API_VERSION, FINANCIAL_SCHEMA_VERSION, @@ -97,6 +98,13 @@ def _financial_node_from_legacy( accounting = _object(result["attributes"].get("accounting")) or accounting_default if _has_value(accounting): result["accounting"] = json.loads(json.dumps(accounting)) + overlay = normalize_deployment_overlay( + node.get("deployment_overlay") if isinstance(node.get("deployment_overlay"), dict) else {}, + result["attributes"], + result["containment"], + ) + if _has_value(overlay): + result["deployment_overlay"] = overlay for key in ("canon_category", "canon_anchor", "mapping_fit"): if node.get(key): result[key] = node[key] @@ -113,6 +121,12 @@ def _financial_edge_from_legacy(edge: dict[str, Any]) -> dict[str, Any]: for key in ("canonical_type", "canon_anchor", "mapping_fit", "display_only", "evidence_state"): if key in edge: result[key] = edge[key] + overlay = normalize_deployment_overlay( + edge.get("deployment_overlay") if isinstance(edge.get("deployment_overlay"), dict) else {}, + result["attributes"], + ) + if _has_value(overlay): + result["deployment_overlay"] = overlay return result diff --git a/railiance_fabric/graph.py b/railiance_fabric/graph.py index f1a6009..b542327 100644 --- a/railiance_fabric/graph.py +++ b/railiance_fabric/graph.py @@ -340,9 +340,12 @@ def _export_attributes(declaration: Declaration) -> dict[str, Any]: def _base_export_attributes(declaration: Declaration) -> dict[str, Any]: source_links = declaration.metadata.get("source_links", []) - return { + attributes = { "owner": declaration.metadata.get("owner", ""), "description": declaration.spec.get("description", ""), "source_path": str(declaration.path), "source_links": source_links if isinstance(source_links, list) else [], } + if isinstance(declaration.spec.get("deployment_overlay"), dict): + attributes["deployment_overlay"] = declaration.spec["deployment_overlay"] + return attributes diff --git a/railiance_fabric/graph_explorer.py b/railiance_fabric/graph_explorer.py index f7b71f5..22763d4 100644 --- a/railiance_fabric/graph_explorer.py +++ b/railiance_fabric/graph_explorer.py @@ -7,6 +7,7 @@ from typing import Any from urllib.parse import urlparse from .canon import edge_canon_mapping +from .deployment_overlay import graph_explorer_overlay_fields, normalize_deployment_overlay DISPLAY_STATES = ("show", "blur", "hide", "highlight", "remove") @@ -134,6 +135,12 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "layer", "kind", "environment", + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", "serverHost", "lifecycle", "unresolved", @@ -147,7 +154,17 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "repo", "domain", "environment", + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", "serverHost", + "routeHost", + "routeHostname", + "routePort", + "routeProtocol", "kind", "layer", "edgeType", @@ -163,7 +180,15 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: {"id": "repo", "label": "Repo", "type": "string"}, {"id": "domain", "label": "Domain", "type": "string"}, {"id": "environment", "label": "Environment", "type": "string"}, + {"id": "deploymentEnvironment", "label": "Deployment Environment", "type": "string"}, + {"id": "deploymentScenario", "label": "Deployment Scenario", "type": "string"}, + {"id": "routingAuthority", "label": "Routing Authority", "type": "string"}, + {"id": "accessZone", "label": "Access Zone", "type": "string"}, + {"id": "policyAuthority", "label": "Policy Authority", "type": "string"}, + {"id": "exposureClass", "label": "Exposure Class", "type": "string"}, {"id": "serverHost", "label": "Server Host", "type": "string"}, + {"id": "routeHost", "label": "Route Host", "type": "string"}, + {"id": "routePort", "label": "Route Port", "type": "number"}, {"id": "lifecycle", "label": "Lifecycle", "type": "string"}, {"id": "reviewState", "label": "Review State", "type": "string"}, {"id": "unresolved", "label": "Unresolved", "type": "boolean"}, @@ -193,6 +218,14 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: "repo", "domain", "lifecycle", + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", + "routeHost", + "routePort", "canonCategory", "evidenceState", "unresolved", @@ -215,6 +248,31 @@ def fabric_graph_explorer_manifest(base_url: str = "") -> dict[str, Any]: ], }, "modes": [ + { + "id": "by-fabric", + "label": "Fabric", + "description": "Group and filter by fabric/accountability fields.", + }, + { + "id": "by-deployment-environment", + "label": "Environment", + "description": "Group and filter by deployment environment.", + }, + { + "id": "by-deployment-scenario", + "label": "Scenario", + "description": "Group and filter by deployment scenario.", + }, + { + "id": "by-routing-authority", + "label": "Routing", + "description": "Group and filter by routing authority.", + }, + { + "id": "by-access-zone", + "label": "Access Zone", + "description": "Group and filter by intended access zone.", + }, { "id": "full", "label": "Full", @@ -335,6 +393,10 @@ def fabric_graph_explorer_payload( if source_kind == "Repository": continue attributes = node.get("attributes") if isinstance(node.get("attributes"), dict) else {} + overlay_data = _overlay_data( + node.get("deployment_overlay") if isinstance(node.get("deployment_overlay"), dict) else {}, + attributes, + ) kind = _presentation_kind(source_kind, attributes) layer = _layer_for_kind(kind) is_unresolved = node_id in unresolved @@ -353,6 +415,8 @@ def fabric_graph_explorer_payload( "repo": str(node.get("repo", "")), "domain": str(node.get("domain", "")), "lifecycle": str(node.get("lifecycle", "")), + "environment": overlay_data["deploymentEnvironment"], + **overlay_data, "canonCategory": str( node.get("canon_category") or attributes.get("canon_category") or "" ), @@ -522,12 +586,17 @@ def _presentation_edge_type(edge_type: str, source: str, target: str, node_kinds def _edge_metadata(edge: dict[str, Any], edge_type: str) -> dict[str, Any]: canon_mapping = edge_canon_mapping(edge_type) + attributes = edge.get("attributes") if isinstance(edge.get("attributes"), dict) else {} return { "canonical_type": str(edge.get("canonical_type") or canon_mapping.canonical_type), "canon_anchor": str(edge.get("canon_anchor") or canon_mapping.canon_anchor), "mapping_fit": str(edge.get("mapping_fit") or canon_mapping.fit), "display_only": bool(edge.get("display_only", canon_mapping.display_only)), "evidence_state": str(edge.get("evidence_state") or "declared"), + "deployment_overlay": normalize_deployment_overlay( + edge.get("deployment_overlay") if isinstance(edge.get("deployment_overlay"), dict) else {}, + attributes, + ), } @@ -630,6 +699,12 @@ def _append_infrastructure_elements( for endpoint in service_endpoints if _environment_matches(environment, endpoint["environments"]) ] + deployment_overlay = normalize_deployment_overlay( + attributes, + {"deployment_environment": environment}, + *[endpoint["deployment_overlay"] for endpoint in matching_endpoints], + ) + overlay_data = _overlay_data(deployment_overlay) server_hosts = sorted({endpoint["host"] for endpoint in matching_endpoints}) deployment_data = { "id": deployment_id, @@ -643,6 +718,7 @@ def _append_infrastructure_elements( "domain": str(service.get("domain") or ""), "lifecycle": str(service.get("lifecycle") or ""), "environment": environment, + **overlay_data, "serviceId": service_id, "serverHosts": server_hosts, "reviewState": "accepted", @@ -676,6 +752,10 @@ def _append_infrastructure_elements( host = endpoint["host"] port = endpoint["port"] protocol = endpoint["protocol"] + endpoint_overlay_data = _overlay_data( + endpoint["deployment_overlay"], + {"deployment_environment": environment}, + ) server_id = server_ids_by_host.get(host) endpoint_key = _endpoint_key(host, port, protocol) port_id = port_ids_by_endpoint.get(endpoint_key) @@ -695,6 +775,7 @@ def _append_infrastructure_elements( "domain": str(service.get("domain") or ""), "lifecycle": "active", "environment": environment, + **endpoint_overlay_data, "serverHost": host, "reviewState": "accepted", "freshnessState": "current", @@ -706,6 +787,7 @@ def _append_infrastructure_elements( "host": host, "source_interface_id": endpoint["interface_id"], "endpoint_url": endpoint["url"], + "deployment_overlay": endpoint_overlay_data["deploymentOverlay"], }, "displayState": "show", "visibilitySource": "default", @@ -731,6 +813,7 @@ def _append_infrastructure_elements( "domain": str(service.get("domain") or ""), "lifecycle": "active", "environment": environment, + **endpoint_overlay_data, "serverHost": host, "reviewState": "accepted", "freshnessState": "current", @@ -744,6 +827,7 @@ def _append_infrastructure_elements( "protocol": protocol, "source_interface_id": endpoint["interface_id"], "endpoint_url": endpoint["url"], + "deployment_overlay": endpoint_overlay_data["deploymentOverlay"], }, "displayState": "show", "visibilitySource": "default", @@ -783,6 +867,16 @@ def _endpoints_by_service(source_nodes: list[dict[str, Any]]) -> dict[str, list[ "url": url, "interface_id": str(node.get("id") or ""), "environments": _environments(attributes), + "deployment_overlay": normalize_deployment_overlay( + attributes, + endpoint, + { + "host": host, + "port": port, + "protocol": protocol, + "route": url, + }, + ), } ) return endpoints @@ -895,6 +989,7 @@ def _edge_element( mapping_fit: str = "", display_only: bool = False, evidence_state: str = "", + deployment_overlay: dict[str, Any] | None = None, ) -> dict[str, Any]: source_layer = node_layers.get(source, "unknown") target_layer = node_layers.get(target, "unknown") @@ -910,6 +1005,7 @@ def _edge_element( strength = _edge_strength(edge_type) layout = _layout_hints(edge_type, source_layer, target_layer, same_repo) edge_id = f"edge:{edge_index}:{source}:{edge_type}:{target}" + overlay_data = _overlay_data(deployment_overlay or {}) return { "data": { "id": edge_id, @@ -930,6 +1026,7 @@ def _edge_element( "mappingFit": mapping_fit, "displayOnly": display_only, "evidenceState": evidence_state, + **overlay_data, "strength": strength, "edgeWidth": _edge_width(strength), "sameLayer": source_layer == target_layer, @@ -959,6 +1056,10 @@ def _edge_element( } +def _overlay_data(*sources: object) -> dict[str, Any]: + return graph_explorer_overlay_fields(normalize_deployment_overlay(*sources)) + + def _layout_hints( edge_type: str, source_layer: str, diff --git a/railiance_fabric/graph_explorer_ui.py b/railiance_fabric/graph_explorer_ui.py index c937c4b..5cef4c7 100644 --- a/railiance_fabric/graph_explorer_ui.py +++ b/railiance_fabric/graph_explorer_ui.py @@ -574,7 +574,10 @@ def graph_explorer_page() -> str: const elementText = (data) => [ data.id, data.stableKey, data.label, data.name, data.description, - data.repo, data.domain, data.kind, data.layer, data.edgeType + data.repo, data.domain, data.kind, data.layer, data.edgeType, + data.deploymentEnvironment, data.deploymentScenario, data.routingAuthority, + data.accessZone, data.policyAuthority, data.exposureClass, + data.routeHost, data.routeHostname, data.routePort, data.routeProtocol, data.route ].join(" ").toLowerCase(); const overrideKey = (element) => element.data("stableKey") || element.id(); @@ -614,6 +617,37 @@ def graph_explorer_page() -> str: const selectedEdgeTypes = () => checkedValues(edgeTypeFilter); + const hasValue = (value) => value !== undefined && value !== null && value !== ""; + + const overlayRuleAttributes = [ + "deploymentEnvironment", + "deploymentScenario", + "routingAuthority", + "accessZone", + "policyAuthority", + "exposureClass", + "routeHost", + "routeHostname", + "routePort", + "routeProtocol", + ]; + + const zoneModeFields = { + "by-fabric": ["domain", "repo", "kind"], + "by-deployment-environment": ["deploymentEnvironment"], + "by-deployment-scenario": ["deploymentScenario"], + "by-routing-authority": ["routingAuthority"], + "by-access-zone": ["accessZone"], + }; + + const zoneModeLabels = { + "by-fabric": "Fabric", + "by-deployment-environment": "Deployment Environment", + "by-deployment-scenario": "Deployment Scenario", + "by-routing-authority": "Routing Authority", + "by-access-zone": "Access Zone", + }; + const summarizeSelection = (selected, allValues, labels, noun) => { if (selected.size === 0) return `No ${noun}`; if (selected.size === allValues.length) return `All ${noun}`; @@ -657,11 +691,21 @@ def graph_explorer_page() -> str: strength: "Strength", sameRepo: "Same repo", layoutAffinity: "Layout affinity", + deploymentEnvironment: "Deployment Environment", + deploymentScenario: "Deployment Scenario", + routingAuthority: "Routing Authority", + accessZone: "Access Zone", + policyAuthority: "Policy Authority", + exposureClass: "Exposure Class", + routeHost: "Route Host", + routeHostname: "Route Hostname", + routePort: "Route Port", + routeProtocol: "Route Protocol", }; const ruleAttributeCandidates = { - node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle"], - edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity"], + node: ["any", "repo", "kind", "reviewState", "unresolved", "lifecycle", ...overlayRuleAttributes], + edge: ["any", "repo", "kind", "reviewState", "unresolved", "strength", "sameRepo", "layoutAffinity", ...overlayRuleAttributes], }; const renderOptions = (select, options, current = "") => { @@ -835,6 +879,79 @@ def graph_explorer_page() -> str: (!type || edge.data("edgeType") === type) ) : []; + const routeLabel = (data) => { + if (hasValue(data.route)) return data.route; + const host = data.routeHost || data.routeHostname || ""; + const protocol = data.routeProtocol || ""; + const port = data.routePort || ""; + if (!hasValue(host) && !hasValue(port)) return ""; + const authority = hasValue(port) ? `${host}:${port}` : host; + return [protocol, authority].filter(hasValue).join(" "); + }; + + const hasRouteEvidence = (data) => + hasValue(data.route) || + hasValue(data.routeHost) || + hasValue(data.routeHostname) || + hasValue(data.routePort); + + const zoneWarningsForData = (data) => { + const warnings = []; + if (hasRouteEvidence(data) && !hasValue(data.policyAuthority)) { + warnings.push("route without policy authority"); + } + return warnings; + }; + + const groupCounts = (elements, field) => { + const counts = new Map(); + elements.forEach((element) => { + const value = element.data(field); + if (hasValue(value)) counts.set(String(value), (counts.get(String(value)) || 0) + 1); + }); + return Array.from(counts.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); + }; + + const renderMapOverview = () => { + const visibleElements = cy + ? cy.elements().filter((element) => element.style("display") !== "none").toArray() + : []; + const visibleNodes = visibleElements.filter((element) => element.isNode()).length; + const visibleEdges = visibleElements.filter((element) => element.isEdge()).length; + const fields = zoneModeFields[activeMode] || []; + const title = zoneModeLabels[activeMode] || "Full"; + detailTitle.textContent = activeMode === "full" ? "Fabric Map" : `${title} View`; + detailSummary.textContent = `${visibleNodes} nodes / ${visibleEdges} edges visible`; + detailPills.innerHTML = activeMode === "full" ? "" : `${escapeHtml(activeMode)}`; + + const rows = [{label: "visible", value: `${visibleNodes} nodes / ${visibleEdges} edges`}]; + fields.forEach((field) => { + const counts = groupCounts(visibleElements, field); + rows.push({ + label: ruleAttributeLabels[field] || humanize(field), + value: counts.length + ? counts.slice(0, 8).map(([value, count]) => `${value} (${count})`).join(", ") + : "no annotated visible entities", + state: counts.length ? "" : "warning", + }); + }); + + const warnings = visibleElements + .flatMap((element) => zoneWarningsForData(element.data()).map((warning) => + `${elementLabel(element)}: ${warning}` + )); + warnings.slice(0, 6).forEach((warning) => rows.push({label: "warning", value: warning, state: "warning"})); + if (warnings.length > 6) { + rows.push({label: "warning", value: `${warnings.length - 6} additional route warnings`, state: "warning"}); + } + + detailList.innerHTML = rows + .filter((row) => row.value) + .map((row) => `