Mesh UI

Benzene.Mesh.Ui ships a self-contained, dependency-free HTML viewer over Benzene's service mesh — the same visual family as Spec UI, styled with the same design tokens, but one level up: catalog-of-services instead of catalog-of-topics.

The Mesh Explorer (mesh-ui.html) is a published mesh artifact snapshot — service health, contract drift, cross-service topics and topology — read from the manifest.json/services/*.json (and optionally topics.json/topology.json/usage.json) produced by Benzene.Mesh.Aggregator. Its realistic deployment is a static file host: copy the HTML next to the aggregator's generated JSON. The page is theme-aware (light/dark) and renders with no external requests, so it works offline and behind strict CSPs.

When a live Benzene.Mesh.Collector is reachable, the same page also grows a live Fleet plane — the catalog is the spine and the live data (health, observed-vs-declared consumers, recent trace flows, and a Fleet landing view) enriches it in place, rather than living on a separate page. This is opt-in via a single wire-envelope endpoint (see below); with none configured the page is the static explorer exactly as described above.

Mesh Explorer

2026-07-22: the explorer has grown into a three-entity product — Estate, Topic (#topic:<id>), and Service (#service:<name>) pages, all hash-deep-linkable — with a node-link topology graph above the edge table, per-entity usage sections fed by the mesh usage feed (usage.json), an estate-level Value & deprecation ranking (structural + observed-usage evidence, run-over-run change flags, topics removed since the previous run), and per-entity Discussion (below). Every artifact stays optional: a missing file hides its sections, never breaks the page. The sections below describe the original core; see src/Benzene.Mesh.Ui/CLAUDE.md for the full, current feature inventory.

Setup: what is wired, what is not, and what is failing

The mesh is designed to work on a subset of what it could know — a service that publishes a spec but no health endpoint is still a service, an estate with no collector still has a catalog, an aggregator with no usage source still publishes topics. So the page never treats a missing piece as an error on the screen that piece would have fed. Every page renders only what it can stand behind (an unreadable feed still reads unknown, never zero), and every explanation of why a section is missing lives on one screen: Setup (#setup), the last entry in the nav.

Setup has two halves. This mesh lists each capability — catalog, topics, topology, usage feed, live plane, dispatch, refresh, sign-in, environment label — with its state, what it unlocks, and how a host wires it (which data-* attribute or MeshUiPage.GetHtml(...) argument, which package). The states are deliberately four: ready, not wired (the ordinary shape of a partial mesh, never counted as a problem), degraded (wired and answering, with a caveat — a live plane that is connected but has never seen traffic) and failing (wired and broken — a 503 on topics.json, a collector that never answers). Services is one row per catalogued service showing which of the mesh's feeds it is actually supplying — reachable, declares topics, reporting to the collector, attributed in the usage feed — with a one-line "what to do" for each gap (never reported → add mesh reporting to the service; unreachable → check its well-known paths; and so on), plus any service the collector sees that the aggregator never catalogued.

The nav's Setup button carries a count of the failing and degraded items only, so a partial mesh working as configured shows no number at all, and a broken feed shows one — in the same place on every screen — rather than a red banner above the estate. Setup is reachable even when manifest.json itself cannot be loaded; it is the page that explains that too.

Shows a stats bar (total/healthy/unhealthy/unreachable/drift counts) and a searchable list of service cards — name, an optional owning-team label, status badge, drift badge, links to the service's raw spec/health URLs, and (when its spec advertised any) a chip row of the transports that service is wired to receive messages over (e.g. http, sqs) — lifted from the benzene spec's document-level transports field (see Spec), silently omitted for a service whose spec predates the field or advertised none. Expanding a card lazily fetches that service's per-service JSON and renders its health-check detail (type, status, dependencies, data). When the aggregator also published topics.json and topology.json, the page additionally renders the cross-service topic catalog and a sortable edge table (client, server, source, req/min, error rate, p50/p95/p99 latency) — either section is silently hidden if its file is missing, since most deployments won't have both wired up.

The topic catalog is keyed by (topic, version), not topic id alone, and shows both directions of exposure — Producers (services whose spec declares sending it) and Consumers (services that handle it, with their HTTP mappings) — plus a computed Status: deprecation candidate (produced somewhere, consumed nowhere — a prompt to check whether it's safe to retire, not proof that it is) or gap (consumed somewhere with no HTTP mapping, produced nowhere in this fleet — often a legitimate third-party or non-Benzene producer, worth confirming rather than an error). Both are informational signals computed by the aggregator across every service's self-description at once — no individual service is ever asked whether its own topics are still in use. See Benzene.Mesh.Aggregator/CLAUDE.md's "Aggregated topic catalog" section for the full computation.

Every producer/consumer name is clickable — it scrolls to, opens, and briefly highlights that service's card above, so "who's still consuming this" is never more than one click away from "is that service actually healthy right now." A card hidden by an active search filter is revealed automatically when you jump to it this way.

Clicking a topic id opens its topic page — a full view swap (the service list, topic table, and topology all step aside; a Back button returns to them), not a small popup, grouping every version of that topic together with a section per version. Each version's Producers and Consumers sections render the real service card for every service involved — the same accordion, status/drift badges, owning-team label, transport chips, spec/health/spec-ui links, and lazy health-check detail as the main service list, not just a name — so a producer or consumer can be drilled straight into from the topic page itself, with no separate lookup, and its transport chips answer "how would I actually reach this topic on this service" right there. A producer/ consumer name with no matching entry in the manifest (e.g. a system outside this fleet feeding a gap topic) renders as a plain placeholder instead of a broken card. Every embedded card's own topics link still works from inside the topic page too — it returns to the main view and re-filters the topic table, so drilling from a topic into one of its services and back into a different topic that service touches is a few clicks, not a dead end.

Opening a topic updates the URL to …#topic:<id> — copy it straight out of the address bar to share or bookmark a link to that exact topic; opening that URL later (or just refreshing) reopens the same page once topics.json has loaded. Leaving the page, however you leave it (the Back button, Escape, or a link inside it navigating elsewhere), clears the hash again. Browsing between topics is real navigation — the browser's own Back/Forward buttons move through topic views the same way clicking around the page does.

The topic table has its own search box, matching against the topic id or any producer/ consumer service name — useful once a fleet has more topics than fit on screen, and it doubles as the reverse cross-link: every service card also has a topics link that pre-fills this search with that service's name and scrolls to the table, answering "which topics does orders-api touch" from the service side instead of hunting through rows by hand.

Discussion & annotations

Topic and service pages carry a Discussion section — the decisions the explorer's evidence provokes ("retire this after finance signs off", "this drift is the planned v2 migration") get recorded next to the evidence instead of evaporating into chat. It is split across the two data planes so the static floor survives:

Serving it

Transport-agnostic HTTP middleware — works on AWS Lambda API Gateway, Azure Functions, ASP.NET Core, or the self-host server, though the primary path is a plain static file host: publish mesh-ui.html into the same directory/bucket the aggregator writes its artifacts to.

using Benzene.Mesh.Ui; // UseMeshUi

app.UseApiGateway(http => http
    .UseMeshUi()                                   // serves GET /benzene/mesh-ui, fetching ../manifest.json
    .UseMessageHandlers()
);

// Customise the path and/or the manifest URL it fetches:
app.UseApiGateway(http => http
    .UseMeshUi("/dashboard", "https://cdn.example.com/mesh/manifest.json")
    .UseMessageHandlers()
);

Browse to /benzene/mesh-ui (the default path; its companion per-service spec page is /benzene/mesh-spec-ui.html). The default manifest URL is resolved against the page's depth, so it still names the root-level manifest.json the artifact middleware serves; a manifest URL you pass is used as given. With no manifest reachable it falls back to ?url= on the query string, a data-manifest-url attribute, or (if none of those resolve) a built-in sample so the layout is visible standalone.

Serving it yourself

var html = MeshUiPage.GetHtml("https://cdn.example.com/mesh/manifest.json");
// write `html` with content-type "text/html"

The live Fleet plane

When you point the page at a live Benzene.Mesh.Collector, the catalog is enriched with derived fleet data — from registrations (each service's contract document), heartbeats, and trace events the collector has actually ingested, never hand-declared, per mesh.md §2–§4:

Because these poll live, an unreachable collector degrades honestly ("collector unreachable — retrying") rather than showing an empty or stale page; the static catalog underneath is unaffected.

Serving it

The Fleet plane is folded into UseMeshUi — pass the wire-envelope envelopeUrl the page should poll:

using Benzene.Mesh.Ui; // UseMeshUi

app.UseApiGateway(http => http
    // The catalog page, enriched with the live Fleet plane polling /benzene/invoke:
    .UseMeshUi(envelopeUrl: "/benzene/invoke")
    // The collector behind that endpoint (queries only, or queries + ingestion):
    .UseBenzeneMessage(new BenzeneMessageHttpOptions { Path = "/benzene/invoke" },
        collector => collector.UseMessageHandlers(MeshCollectorHandlers.All))
    .UseMessageHandlers()
);

The envelopeUrl can be a same-origin path (the common case: the mesh host also fronts the collector) or an absolute URL to a collector reachable elsewhere. Omit it (the default is null) and the page is the static explorer with the Fleet plane dormant. The endpoint the page polls is the same wire-envelope endpoint (/benzene/invoke by default, MeshUiExtensions.DefaultEnvelopeUrl) that services use to register, heartbeat, and export traces.

UseMeshUi takes four more optional parameters, each defaulting to null = the feature stays off — every one is a separate opt-in precisely so wiring one capability never silently switches on another:

| Parameter | What passing it turns on | |---|---| | dispatchUrl | The Test Console's send button — the wire-envelope endpoint it POSTs mesh:dispatch messages to (pair with Benzene.Mesh.Dispatch's UseMeshDispatch() on the host; that package additionally refuses dispatch in Production unless AllowInProduction is set). Without it the console is compose-and-copy only. | | logoutUrl | A Sign-out control, pointing at the host's OIDC logout route (e.g. Benzene.Mesh.Auth.Oidc's {BasePath}/logout). An ungated page has nothing to sign out of, so there's no default. | | refreshUrl | A Refresh control that POSTs to trigger a discovery/aggregation pass. Passing it is a statement the host also guards that endpoint — Benzene.Mesh.Artifacts' UseMeshRefreshGuard() is the matching server side (the page's POST carries the X-Benzene-Refresh header it requires). | | environment | A free-text estate label (production, staging, dev-pr-412) rendered in the page chrome on every screen, so a dev mesh and a production mesh aren't identical on screen. Configured at deploy time, never inferred from a hostname; unset, the page says the environment is unpublished rather than guessing. | See examples/Mesh/README.md for a runnable end-to-end demo (./run.sh) with real services registering, heartbeating, and tracing into the live Fleet plane, and examples/AwsMesh/Mesh/Startup.cs for the AWS wiring (X-Ray + CloudWatch behind the envelope).

Serving it yourself

// Inject both the manifest URL and the live-fleet envelope URL onto the page:
var html = MeshUiPage.GetHtml("https://cdn.example.com/mesh/manifest.json",
                              "https://collector.example.com/benzene/invoke");
// write `html` with content-type "text/html"

Time range

The Fleet plane carries a time-range picker (following Grafana's relative-time grammar — now-5m, now-1h, now-7d, …) with presets 5m / 15m / 1h / 6h / 24h / 7d, All time, and a custom absolute from/to. One shared range (default 1h) drives the whole live plane — the Fleet landing view and the per-entity live sections it feeds — and is applied server-side on the mesh:query:fleet / mesh:query:correlation queries (a trace lookup is by id, so it takes no window). It appears only on the live plane; the static catalog and its usage.json window are untouched.

The picker windows the flows (recent-flows, correlation) on every plane. Counts are subtler and the UI is honest about it. On the push-collector plane the per-topic/service counters are cumulative-since-start and can't be sub-windowed, so the response says countsWindowed: false and the count tiles carry a "counts are cumulative from … — flows are windowed, counters aren't" badge rather than a blanked or refiltered number. On the composite (X-Ray + CloudWatch/App-Insights) plane the counts do honor the picked window — the metrics adapters query their backend over exactly the selected range — so countsWindowed is true and no badge shows, unless a usage source in the mix can't window (e.g. the cumulative collector feed), in which case the union isn't windowed and the badge returns. A null/absent window (All time, or an old client) reproduces the pre-picker unfiltered behavior exactly.

Cost note (AWS/Azure): on the composite plane a wider selected range now also drives the usage query. That's negligible for CloudWatch (GetMetricData is billed per metric, not per datapoint) but real for Azure Log Analytics (billed on data scanned) — a 7-day range raises the App-Insights query cost. The X-Ray recent-flows scan is window-sensitive on both.

This is a meshed service's intended visibility surface (mesh.md §7.1: a Cloud Service plus M1, whose registration, heartbeats and traces are exactly the feeds the Fleet plane reads, and M2, trace propagation); mesh.md §9 covers how the artifact and collector pipelines converge.