You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/08/27 08:41:33 UTC

(skywalking-horizon-ui) branch main updated: feat: one coordinated refresh round, and a retention page shaped by its backend (#140)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new f97b19b3 feat: one coordinated refresh round, and a retention page shaped by its backend (#140)
f97b19b3 is described below

commit f97b19b33e731752c550651e976b9f8dac2f004f
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Thu Aug 27 16:41:25 2026 +0800

    feat: one coordinated refresh round, and a retention page shaped by its backend (#140)
    
    * feat(refresh): refresh the whole page as one coordinated round
    
    A page refreshed in pieces. The header and the service roster followed the
    topbar timer, a dashboard's widgets and the alarms card each polled on
    intervals of their own, and every graph re-keyed itself off the clock. The page
    redrew in waves; on a slow backend a new wave began before the last had
    finished; and each tick blanked the maps to a loading line, discarding the
    operator's zoom, pan and node placements with them.
    
    The graphs made it worst, because the BFF's graph routes never throw: an OAP
    failure arrives as HTTP 200 with `{ nodes: [], reachable: false }`. A query
    library sees a success, replaces the cached graph with the empty one, and the
    canvas goes blank — nothing pending to wait on, no error to retry.
    Mid-incident, that is the moment the picture disappears.
    
    **Refreshing is one round.** The store re-anchors the rolling window once,
    calls every subscriber, waits for them all, and only then counts toward the
    next round. The countdown measures the gap between rounds rather than between
    starts: while readings are out it says "Refreshing", and it starts again from
    the landing, so loading time is never charged against the interval. A round
    that has started always finishes; switching off or entering a page that pauses
    refreshing stops the NEXT one, because abandoning a round halfway leaves the
    page holding whatever fraction landed beside values from the round before.
    
    Each round publishes what it is — id, trigger, window, step, stage — so every
    screen in it agrees about the question. Participants take the window from the
    round rather than re-deriving it, but only while it still describes what the
    operator is asking: the picker stays live, and a range changed mid-round would
    otherwise file an answer about the old window under the key that names the new
    one. A trigger arriving mid-round is coalesced into one trailing round, and an
    operator's action — a click, a time change, a stage flip — survives the
    suspension a custom range imposes.
    
    The sixty-second cap CANCELS. `AbortSignal` runs from the browser through the
    BFF to OAP, and a round names every query it holds — a fan-out is one
    participant with many — so a capped round stops its work rather than releasing
    the scheduler while it continues. It also records itself, because a cap that
    leaves no trace is indistinguishable from a round that merely took a while.
    Reads only: anything that CHANGES something runs to completion, so a closed tab
    cannot leave a profiling task half created.
    
    Every OAP call on a read route takes that signal, including the two probes
    that are easy to miss because no route names them: the timezone lookup and the
    capability introspection. Both also refuse to CACHE a result derived from a
    probe that was cancelled — a UTC-0 offset held for a minute, an all-false
    capability set held for another — because one abandoned request must not answer
    every other request with a conclusion nobody reached.
    
    Cancelling only works if nothing asks again. The retry policy therefore exempts
    a request we cancelled as well as an answer that cannot differ, and the three
    queries that had overridden it with a plain `retry: 1` — the layer landing and
    both dashboard fan-outs — inherit it again. Retrying an aborted fan-out is
    worse than never capping it: the work restarts with the cap already spent.
    
    Whether the caller is still listening is asked of the RESPONSE socket alone. A
    request's own readable is destroyed as soon as its body has been read — Node
    auto-destroys it on `end`, so it reads as destroyed in every handler behind an
    async preHandler, which is every authenticated read route. Consulting it
    reported each of those reads as abandoned and cancelled it before it issued a
    single query, while the route still replied with a well-formed 200 saying OAP
    could not be reached.
    
    **Every screen keys on the QUESTION, not the clock.** A graph query is keyed on
    one structured predicate — layer, focus, service, endpoint, depth, preview,
    stage and the window's identity — while the bounds travel as the request
    argument. The overview keyed on raw bounds and emptied its grid every tick, the
    layer dashboard bucketed them to the minute and emptied every other one, and
    the roster refreshed itself by accident, outside any round. A round now
    produces one request per logical query: refetching used to cancel what was in
    flight and start again, so two components sharing a query issued it twice.
    
    **A response that cannot be drawn is a failure, not data.** It is thrown rather
    than returned, so the last good answer survives both a failed round and a
    remount by living in the query cache. A five-state phase machine is derived
    from the snapshot and the latest attempt together, so no view reads half its
    picture from each, and a first failure says so instead of showing a loading
    line for ever. Everything describing the drawn graph comes from the snapshot;
    only what describes the failure comes from the attempt. The rule reaches the
    roster and the overview too, where an unreadable answer had been rendering as a
    page of zeroes — indistinguishable from a system with nothing running.
    
    **Failures route by who asked.** The timer's collect beside the refresh
    control, five deep, with an unread count, because nobody asked for that round.
    An operator's own answer where they are looking, as a toast that shows its
    remaining time, pauses on hover or focus, and returns focus when dismissed.
    A refresh THEY asked for does both: it is recorded like any other, and says on
    screen that part of the page failed — once, however many screens failed, since
    they pressed one button. One event is one record however many observers saw it,
    a comparison entity that fails alone is recorded at all, and a request WE
    cancelled is never reported: that is the app's own decision, not an outage.
    Both kinds render the same card; URLs and bodies are recursively redacted and
    length-capped; the history clears on sign-out.
    
    **A refresh moves nothing the operator arranged.** Placements are absolute
    rather than offsets from a layout that shifts beneath them, and are pruned only
    from a snapshot that arrived. The deployment map re-arms its drag handlers on
    the identity of what is drawn rather than on counts. The endpoint columns still
    select live but no longer re-sort themselves every cycle. A replaced canvas is
    restored to the operator's framing instead of starting at identity, and a fit
    taken on an empty canvas no longer latches. Expansions say what they are built
    on: dropped when a new base commits or the question changes, kept when a
    refresh fails, asked with the window their base was read with, and resolved
    against the draft when a preview is open.
    
    **Switching Query cold stage is one event.** The flag and the header are
    written in the same tick, the stage scopes every cache entry so an in-flight
    hot read cannot answer a cold question, and the page is re-read as one round
    rather than a page-wide invalidate.
    
    **The template store keeps the console up.** An unreadable store used to empty
    every dashboard, overview and map; they now render the last rows Horizon read,
    with the banner saying how stale that is. It still never substitutes the
    release's bundled templates — that would misrepresent whose configuration is on
    screen — so a Horizon that has never read the store still blocks. The store is
    re-read on a cycle, so a template published from another Horizon or from swctl
    arrives without a reload and an unreachable store clears itself. Cluster Status
    gains a pane saying what is loaded, when it was read, and what the last failure
    said.
    
    * fix(ttl): one retention catalogue, and say which setting governs each figure
    
    Data retention mirrored OAP's wire shape rather than anything an operator
    decides: a Records / Metrics split, a Minute / Hour / Day trio, and a class
    called "Normal". On ElasticSearch and JDBC that meant nine numbers which were
    really two, presented as nine knobs — those backends register the SAME
    DefaultStorageTTLStatusQuery, which repeats `core.recordDataTTL` across every
    record class and `core.metricsDataTTL` across every metric one. BanyanDB is the
    only storage plugin implementing the TTL query, and the only one where the
    classes are separate groups that can be tuned apart.
    
    Both backends now show the same five, in the same order — Metadata, Metrics,
    Logs, Traces, Others — with the governing setting named beneath each. BanyanDB
    expands Metrics into its minute / hour / day groups and adds Zipkin traces and
    Browser error logs, because there each is its own `bydb.yml` group.
    
    Rows whose retention happens to MATCH are no longer merged. A single
    "All records (5)" bar reads as a fact about the backend when it is only a fact
    about today's configuration, and it hid four rows an operator may want to
    change. `Others` is the one row named for a group rather than a data type:
    `records` holds alarms, alarm recovery, events, the three sampled-trace records,
    the four top-N records, every profiling record and GenAI evaluations — 22 models
    sharing one `ttl` and one `enableColdStage`, so no single data type names it.
    
    The group keys are also not the wire field names: the response says `log` and
    `browserErrorLog` where the groups are `recordsLog` and
    `recordsBrowserErrorLog`. Pointing an operator at the wire name sends them to a
    key that does not exist, so only `records` — the one mapping nobody can guess —
    is printed, and the section says once that a row is a `bydb.yml` group.
    
    Covered by a new `es` spec, since the page's SHAPE is decided by the backend and
    can only be checked where that backend is not BanyanDB. It asserts the catalogue
    and its order, and counts the cards attributed to each of the two settings —
    presence alone would pass with a card wired to the wrong one.
    
    Also moves the OAP wire rules out of the root CLAUDE.md into apps/bff/CLAUDE.md,
    next to the code that speaks them: the four endpoints and what each carries,
    cancellation, time/step/timezone, cold stage, metric entity-scope and TTL's
    per-backend shape. The root file keeps the principle and points there.
---
 CLAUDE.md                                          |  25 +-
 apps/bff/CLAUDE.md                                 | 159 +++++++
 apps/bff/src/client/graphql-cancel.test.ts         | 115 +++++
 apps/bff/src/client/graphql.ts                     |  26 +-
 apps/bff/src/http/client-gone.test.ts              | 135 ++++++
 apps/bff/src/http/client-gone.ts                   |  58 +++
 apps/bff/src/http/query/alarms.ts                  |  18 +-
 apps/bff/src/http/query/dashboard.ts               |   6 +-
 apps/bff/src/http/query/deployment.ts              |  15 +-
 apps/bff/src/http/query/endpoint-dependency.ts     |  16 +-
 apps/bff/src/http/query/endpoint.ts                |   6 +-
 apps/bff/src/http/query/instance-topology.ts       |  15 +-
 apps/bff/src/http/query/instance.ts                |   6 +-
 apps/bff/src/http/query/landing.ts                 |   6 +-
 apps/bff/src/http/query/services.ts                |   5 +-
 apps/bff/src/http/query/topology.ts                |  31 +-
 apps/bff/src/logic/layers/effective.test.ts        | 177 +++++++-
 apps/bff/src/logic/layers/effective.ts             |  50 ++-
 apps/bff/src/logic/oap/capabilities.test.ts        |  20 +
 apps/bff/src/logic/oap/capabilities.ts             |  20 +-
 apps/bff/src/logic/preflight/preflight.ts          |   6 +
 .../src/logic/services/service-layer-catalog.ts    |  15 +-
 apps/bff/src/logic/templates/sync.ts               | 109 ++++-
 apps/bff/src/util/window.test.ts                   |  18 +
 apps/bff/src/util/window.ts                        |  12 +-
 apps/ui/src/api/client.ts                          |  56 ++-
 apps/ui/src/api/queryClient.test.ts                |  69 +++
 apps/ui/src/api/queryClient.ts                     |  25 +-
 apps/ui/src/api/scopes/layer.ts                    |  26 +-
 apps/ui/src/controls/ErrorRecordCard.test.ts       | 107 +++++
 apps/ui/src/controls/ErrorRecordCard.vue           | 133 ++++++
 apps/ui/src/controls/ErrorToastHost.vue            | 225 ++++++++++
 apps/ui/src/controls/RefreshErrorPanel.vue         | 180 ++++++++
 apps/ui/src/controls/autoRefresh.test.ts           | 450 +++++++++++++++++++
 apps/ui/src/controls/autoRefresh.ts                | 482 ++++++++++++++++++--
 apps/ui/src/controls/coldStage.test.ts             | 181 ++++++++
 apps/ui/src/controls/coldStage.ts                  | 113 ++++-
 apps/ui/src/controls/configBundle.ts               |  39 ++
 apps/ui/src/controls/configBundleSync.test.ts      |  86 ++++
 apps/ui/src/controls/errorCenter.test.ts           | 373 +++++++++++++++
 apps/ui/src/controls/errorCenter.ts                | 428 ++++++++++++++++++
 apps/ui/src/controls/trafficStarvation.test.ts     | 104 +++++
 apps/ui/src/controls/useAutoRefreshSubscribe.ts    |  82 +++-
 .../features/operate/cluster/ClusterStatusView.vue | 153 +++++++
 apps/ui/src/features/operate/ttl/TtlView.vue       | 210 ++++-----
 apps/ui/src/i18n/locales/de.json                   |  58 ++-
 apps/ui/src/i18n/locales/en.json                   |  58 ++-
 apps/ui/src/i18n/locales/es.json                   |  58 ++-
 apps/ui/src/i18n/locales/fr.json                   |  58 ++-
 apps/ui/src/i18n/locales/ja.json                   |  58 ++-
 apps/ui/src/i18n/locales/ko.json                   |  58 ++-
 apps/ui/src/i18n/locales/pt.json                   |  58 ++-
 apps/ui/src/i18n/locales/zh-CN.json                |  58 ++-
 .../LayerEndpointDependencyView.vue                | 169 ++++++-
 .../useEndpointDependencyExpansion.test.ts         | 210 ++++++++-
 .../useEndpointDependencyExpansion.ts              | 162 ++++++-
 .../useEndpointDependencyLayout.test.ts            | 144 ++++++
 .../useEndpointDependencyLayout.ts                 |  51 ++-
 .../useLayerEndpointDependency.ts                  | 201 +++++++--
 apps/ui/src/layer/graphQuery.test.ts               | 498 +++++++++++++++++++++
 apps/ui/src/layer/graphQuery.ts                    | 467 +++++++++++++++++++
 .../src/layer/service-map/LayerDeploymentView.vue  | 126 +++++-
 .../service-map/LayerInstanceTopologyView.vue      | 105 ++++-
 .../src/layer/service-map/LayerServiceMapView.vue  | 136 +++++-
 apps/ui/src/layer/service-map/hierarchyStore.ts    |   4 +-
 apps/ui/src/layer/service-map/useDeployment.ts     | 144 ++++--
 .../src/layer/service-map/useDeploymentPanZoom.ts  |  91 +++-
 .../src/layer/service-map/useInstanceTopology.ts   | 146 ++++--
 apps/ui/src/layer/service-map/useLayerTopology.ts  | 168 +++++--
 apps/ui/src/layer/service-map/useTopologyCanvas.ts |  95 +++-
 apps/ui/src/layer/useLayerLanding.ts               |  66 ++-
 apps/ui/src/layer/useLayerServices.ts              |  31 +-
 .../render/layer-dashboard/LayerDashboardsView.vue |  27 +-
 .../render/layer-dashboard/useLayerDashboard.ts    | 205 +++++++--
 .../ui/src/render/overview/useOverviewDashboard.ts |  84 +++-
 apps/ui/src/render/widgets/AlarmsWidget.vue        |  43 +-
 apps/ui/src/shell/AppShell.vue                     |  14 +-
 apps/ui/src/shell/AutoRefreshChip.vue              | 112 ++++-
 apps/ui/src/shell/ColdStageChip.vue                |   7 +-
 apps/ui/src/shell/ColdStageTrapBanner.vue          |   2 -
 apps/ui/src/shell/GlobalConnectivityBanner.vue     |  28 +-
 apps/ui/src/shell/TimeRangeChip.vue                |   4 +-
 apps/ui/src/shell/useAdminFeatures.ts              |   5 +
 apps/ui/src/shell/useAlarmCount.ts                 |   5 +
 apps/ui/src/shell/useLayers.ts                     |   5 +
 apps/ui/src/shell/useOapInfo.ts                    |   5 +
 docs/changelog/1.1.0.md                            |  72 +++
 docs/compatibility/cluster-status.md               |  16 +
 docs/compatibility/required-modules.md             |   2 +-
 docs/customization/layer-templates.md              |  15 +
 docs/menu.yml                                      |   2 +
 docs/operate/refreshing.md                         |  54 +++
 packages/api-client/src/deployment.ts              |   9 +
 packages/api-client/src/preflight.ts               |  29 ++
 packages/api-client/src/topology.ts                |  27 ++
 .../playwright/specs/es/ttl-flat-retention.spec.ts |  57 +++
 test/e2e/playwright/specs/ui/graph-refresh.spec.ts | 241 ++++++++++
 97 files changed, 8426 insertions(+), 713 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b2a53d42..bcc4aca3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -24,30 +24,9 @@ When you can't reproduce the user's symptom locally, say so. Don't invent a fix.
 
 ## Backend compatibility
 
-The UI talks to OAP through the **GraphQL query-protocol** and through OAP's admin REST surface. Both contracts are **fixed** — owned by the skywalking repo, not this one.
+The UI talks to OAP through the **GraphQL query-protocol** and through OAP's admin REST surface. Both contracts are **fixed** — owned by the skywalking repo, not this one. **Do not invent fields**: if a screen needs data the protocol doesn't expose, flag it, because the right fix is a query-protocol change upstream rather than a BFF-side fabrication.
 
-- **Do not invent fields.** If a screen needs data the protocol doesn't expose, flag it. The right fix is a query-protocol change upstream, not a UI hack or a BFF-side fabrication.
-- **The schemas and Java implementations are the authoritative spec** — read them (`oap-server/server-query-plugin/.../query-protocol/*.graphqls` and `oap-server/server-core/.../query/`) before guessing at a wire shape. Stand up a local OAP (the SkyWalking repo ships a docker-compose) for smoke-testing wire changes.
-- **When a wire shape is in doubt, read the protocol and ask OAP** — the schema, the Java implementation, then a live server. An older UI's source may show one working answer, but it is evidence about the past, not a decision about the present: it can be as old as the protocol it was written against, and it carries its own workarounds. Never cite it as the reason a thing must be a certain way.
-
-### Metric entity-scope is load-bearing
-
-Every OAP metric lives under exactly one entity scope (Service / ServiceInstance / Endpoint / relations / Process / All). OAP does not auto-rollup between scopes — querying at the wrong scope returns empty results regardless of MQE wrapping. Before adding or moving a metric, verify its scope against the OAP catalog and confirm it matches the page that will render it. Never invent a BFF-side rollup to bridge a scope mismatch — move the metric or leave the slot empty.
-
-### Time, step, and timezone
-
-- **Step precision is page-family-specific.** Dashboards / overviews / landing scale step with the rolling window (MINUTE / HOUR / DAY). Alarms / traces / logs / live debugger use SECOND because they query event-style data anchored at second precision — MINUTE rounding chops off the most recent (most interesting) events. MQE traffic backdrops use MINUTE because metrics are aggregated at minute granularity.
-- **String format is determined by step.** Mixing them throws `verifyDateTimeString` on OAP. Read `DurationUtils.java` in the skywalking repo for the canonical mapping.
-- **OAP has a per-request bucket cap.** Long windows must be chunked. Storage backends impose stricter caps that vary by backend — probe, don't assume.
-- **All time strings are OAP-server local.** Not UTC, not browser-local. The server's offset is exposed via `getTimeInfo`. The BFF owns this conversion; the UI displays in browser-local (echarts handles ms → local natively).
-- **Per-page vs. global time.** The topbar time picker applies only to layer dashboards + overviews. Triage / investigation pages (alarms, traces, logs, profilings, live debugger) own their own time range — do not subscribe them to the global ticker.
-- **Picker wiring is a two-sided contract.** The time range only reaches OAP when the UI composable forwards it AND the BFF route accepts it. Verify the actual request, not the intent.
-
-### Other OAP sharp edges
-
-- **Storage backends have undocumented limits.** Page sizes, nested selections, and per-record sub-queries fail at backend-specific thresholds. Degrade list queries to the cheapest selection that satisfies the screen; probe before defaulting.
-- **OAP IDs are not always per-record unique.** Some wire `id` fields key on the alarmed/related entity, not the firing instance. Disambiguate composite keys with timestamp before using `id` as a row key.
-- **Widget type follows MQE shape.** A widget whose MQE collapses to a single scalar must be `type: "card"`, not `type: "line"`. The tell-tale is the outermost call: `latest(...)`, `max(...)`, `min(...)`, `avg(<plain-metric>)`, `sum(<plain-metric>)` all reduce the window to one number — line-charting a single point is wasteful and misleads operators into thinking the metric is time-varying. Series-shaped wrappers (`relabels(...)`, `top_n(...)`, `histogram*(...)`, `aggregate_labels(...)`  [...]
+**The wire details live with the code that speaks it — see [`apps/bff/CLAUDE.md`](apps/bff/CLAUDE.md).** That file is the reference for the four OAP endpoints and what each carries, cancellation, time/step/timezone, cold stage, metric entity-scope, TTL's per-backend shape, and the rest of the sharp edges. Read it before changing anything that reaches OAP.
 
 ### Template source: `live` means REMOTE, never bundled
 
diff --git a/apps/bff/CLAUDE.md b/apps/bff/CLAUDE.md
new file mode 100644
index 00000000..8555b884
--- /dev/null
+++ b/apps/bff/CLAUDE.md
@@ -0,0 +1,159 @@
+# CLAUDE.md — the BFF's conversation with OAP
+
+Everything about **how Horizon talks to OAP** lives here: which endpoints exist,
+what each one is for, and the wire rules that are easy to get wrong. The root
+`CLAUDE.md` keeps the project-wide principles and points here for this.
+
+`client/` is the ONLY layer that talks to OAP. If you are adding a call from
+anywhere else, that is the bug — read the layering rules in the root file first.
+
+## The endpoints — there are four, and they are not interchangeable
+
+Configured under `oap:` (see `src/config/schema.ts`). Every one is a separate
+URL, and using the wrong one fails in a way that reads like "OAP is broken".
+
+| Config | Default | Carries |
+|---|---|---|
+| `oap.queryUrl` | `http://127.0.0.1:12800` | GraphQL query-protocol + `/status/*` |
+| `oap.adminUrl` | `http://127.0.0.1:17128` | runtime rule mgmt, DSL/MQE/OAL, inspect, live debug, `/ui-management/*` |
+| `oap.zipkinUrl` | `http://127.0.0.1:9412/zipkin` | the Zipkin-compatible trace API |
+| `oap.mqe.host` / `.port` | unset | optional MQE override |
+
+Two things that have cost real time:
+
+- **Template management is on the ADMIN host, not the query port.** OAP moved
+  `/ui-management/templates*` to the admin server in 11.0.0. An OAP without that
+  surface (any 10.x) cannot serve templates to Horizon at all — the supported
+  configuration there is `templates.mode: readonly`, not something Horizon
+  silently falls back to.
+- **Query traffic is load-balanceable, admin traffic is not always.** Any OAP
+  node answers a query. Admin endpoints mostly get a single fire because OAP
+  routes rule operations cluster-internally (`client/cluster.ts` deliberately
+  does NOT fan out) — the exception is live-debug status, which resolves the
+  admin hostname through DNS to find every node IP and probes each.
+
+`oap.auth` (basic) and `oap.timeoutMs` apply to all of them.
+
+## Cancellation
+
+A read the operator abandoned must stop costing OAP work. The caller's
+`AbortSignal` travels route → `buildOapOpts` → GraphQL client, combined with
+rather than replacing the client's own timeout.
+
+- **Reads only.** Anything that MUTATES — creating a profiling task, pushing a
+  template — runs to completion, or a closed tab could leave it half applied.
+  Those routes deliberately pass nothing.
+- **"The client is gone" is asked of the RESPONSE socket alone.** A request's
+  own readable is destroyed as soon as its body has been read, so consulting it
+  reports every authenticated read as abandoned. See `http/client-gone.ts`.
+- **A probe that was cancelled measured nothing, so it must not be CACHED.**
+  `getServerOffsetMinutes` and `getOapCapabilities` both hold process-global
+  caches; writing an abort-derived fallback into either lets one abandoned
+  request answer every other request with a conclusion nobody reached.
+
+## Time, step, and timezone
+
+- **Step precision is page-family-specific.** Dashboards / overviews / landing
+  scale step with the rolling window (MINUTE / HOUR / DAY). Alarms / traces /
+  logs / live debugger use SECOND because they query event-style data anchored
+  at second precision — MINUTE rounding chops off the most recent (most
+  interesting) events. MQE traffic backdrops use MINUTE because metrics are
+  aggregated at minute granularity.
+- **String format is determined by step.** Mixing them throws
+  `verifyDateTimeString` on OAP. Read `DurationUtils.java` in the skywalking
+  repo for the canonical mapping.
+- **OAP has a per-request bucket cap.** Long windows must be chunked. Storage
+  backends impose stricter caps that vary by backend — probe, don't assume.
+- **All time strings are OAP-server local.** Not UTC, not browser-local. The
+  server's offset is exposed via `getTimeInfo`. The BFF owns this conversion;
+  the UI displays in browser-local.
+
+## Cold stage
+
+`Duration.coldStage: true` **REPLACES** the hot+warm read with a cold-only read
+— it does not union the two. The BFF honors the header verbatim and does NOT
+auto-route by time range.
+
+**It is BanyanDB-only, and it is per-group.** Other backends silently ignore the
+flag. In `bydb.yml` each group carries its own `enableColdStage` (default
+`false`), and only these eight expose it: `records`, `trace`, `zipkinTrace`,
+`recordsLog`, `recordsBrowserErrorLog`, `metricsMinute`, `metricsHour`,
+`metricsDay`. (The group keys are not the wire field names — the TTL response
+calls the last two `log` and `browserErrorLog`.)
+
+The flag reaches the BFF as the `x-horizon-cold-stage` header, is stashed once
+per request on `req.coldStage`, and routes splice it in via `withColdStage`.
+**Whether a route calls `withColdStage` is the ground truth for whether that
+data has a cold stage at all** — and the UI mirrors it: a query whose route
+applies the flag needs the stage in its cache key, because an answer from one
+stage is not an answer to the other's question.
+
+**Deliberately NOT cold-staged:** metadata, the instance and endpoint pickers,
+and alarms. Operationally we do not recommend putting these in cold storage —
+they are small, and they are what an operator reaches for first during an
+incident. So their UI queries do not carry the stage in their key either. If
+that recommendation ever changes, both sides change together.
+
+## Metric entity-scope is load-bearing
+
+Every OAP metric lives under exactly one entity scope (Service / ServiceInstance
+/ Endpoint / relations / Process / All). OAP does not auto-rollup between
+scopes — querying at the wrong scope returns empty results regardless of MQE
+wrapping. Before adding or moving a metric, verify its scope against the OAP
+catalog and confirm it matches the page that will render it. Never invent a
+BFF-side rollup to bridge a scope mismatch.
+
+## TTL is shaped by the storage backend
+
+**Only BanyanDB implements per-class TTL.** OAP ships three storage plugins, and
+`BanyanDBTTLStatusQuery` is the sole implementation of the storage-side TTL
+query — the ElasticSearch and JDBC providers both register the *same*
+`DefaultStorageTTLStatusQuery`, so they fall through to `TTLStatusQuery.getTTL()`,
+which builds `RecordsTTL(coreRecordDataTTL ×5)` and
+`MetricsTTL(coreMetricsDataTTL ×4)` — **the same number repeated**. There is no
+third case: it is BanyanDB or it is those two knobs, `core.recordDataTTL` and
+`core.metricsDataTTL`, with the five/four-way breakdown simply not existing.
+(JDBC covers MySQL, PostgreSQL, H2 and TiDB alike.)
+
+Things worth remembering:
+
+- The BanyanDB numbers are **hot plus every additional lifecycle stage**, summed
+  — not the hot TTL alone.
+- **The `bydb.yml` group keys are not the wire field names.** The TTL response
+  says `log` and `browserErrorLog`; the groups are `recordsLog` and
+  `recordsBrowserErrorLog`. Pointing an operator at the wire name sends them to
+  a key that does not exist.
+- **`records` is not "alarms".** That one group holds alarms, alarm recovery,
+  events, the three sampled-trace records, the four top-N records and every
+  profiling record (profile / eBPF / async-profiler / JFR / pprof) plus GenAI
+  evaluations — 22 models sharing one `ttl` and one `enableColdStage`. Naming it
+  after any single one of them is wrong, which is why the UI calls it *Others*.
+- `logic/oap/backend.ts` infers the backend from exactly this: if the record
+  values differ from one another, it is BanyanDB. Anything rendering a per-class
+  breakdown must branch on that, or it presents a structure the backend does not
+  have.
+
+## Other sharp edges
+
+- **Storage backends have undocumented limits.** Page sizes, nested selections,
+  and per-record sub-queries fail at backend-specific thresholds. Degrade list
+  queries to the cheapest selection that satisfies the screen; probe before
+  defaulting.
+- **OAP IDs are not always per-record unique.** Some wire `id` fields key on the
+  alarmed/related entity, not the firing instance. Disambiguate composite keys
+  with timestamp before using `id` as a row key.
+- **Paging is `pageNum`/`pageSize`, never a cursor**, and no total is returned.
+
+## Do not invent fields
+
+The GraphQL query-protocol and the admin REST surface are **fixed** — owned by
+the skywalking repo, not this one.
+
+- If a screen needs data the protocol doesn't expose, flag it. The right fix is
+  a query-protocol change upstream, not a BFF-side fabrication.
+- **The schemas and Java implementations are the authoritative spec** — read
+  `oap-server/server-query-plugin/.../query-protocol/*.graphqls` and
+  `oap-server/server-core/.../query/` before guessing at a wire shape.
+- **When a wire shape is in doubt: the schema, then the Java, then a live
+  server.** An older UI's source is evidence about the past, not a decision
+  about the present. Never cite it as the reason a thing must be a certain way.
diff --git a/apps/bff/src/client/graphql-cancel.test.ts b/apps/bff/src/client/graphql-cancel.test.ts
new file mode 100644
index 00000000..07c98c39
--- /dev/null
+++ b/apps/bff/src/client/graphql-cancel.test.ts
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Who is allowed to end an OAP request.
+ *
+ * Two parties, and the mistake in either direction costs something real. If the
+ * caller's cancellation is ignored, the BFF finishes reads nobody is listening
+ * to — a refresh round that gave up after its cap cancelled the browser's
+ * socket while the queries it started ran on, and on a cluster fan-out that is
+ * one abandoned request multiplied by the node count. If the caller's signal
+ * REPLACES the client's own timeout, a slow OAP has nothing cutting it off at
+ * all whenever a caller happens to supply one.
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+import { buildOapOpts, graphqlPost } from './graphql.js';
+import type { HorizonConfig } from '../config/schema.js';
+
+/** A fetch that never answers until the request is aborted. */
+function hangingFetch(): typeof globalThis.fetch {
+  return ((_url: string, init?: RequestInit) =>
+    new Promise((_resolve, reject) => {
+      const signal = init?.signal;
+      if (!signal) return;
+      if (signal.aborted) {
+        reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+        return;
+      }
+      signal.addEventListener('abort', () => {
+        reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+      });
+    })) as unknown as typeof globalThis.fetch;
+}
+
+const opts = (timeoutMs: number, signal?: AbortSignal) => ({
+  queryUrl: 'http://oap.test:12800',
+  timeoutMs,
+  fetch: hangingFetch(),
+  ...(signal ? { signal } : {}),
+});
+
+describe('a caller can cancel an OAP read', () => {
+  it('ends the request when the caller gives up', async () => {
+    const ac = new AbortController();
+    const inFlight = graphqlPost(opts(60_000, ac.signal), '{ ping }');
+
+    ac.abort();
+
+    await expect(inFlight, 'the caller gave up and the read carried on').rejects.toThrow();
+  });
+
+  it('still enforces its OWN timeout when a caller signal is present', async () => {
+    vi.useFakeTimers();
+    try {
+      // A caller that never cancels must not disarm the timeout protecting
+      // against a slow OAP.
+      const never = new AbortController();
+      const inFlight = graphqlPost(opts(50, never.signal), '{ ping }');
+      const settled = expect(inFlight).rejects.toThrow();
+
+      await vi.advanceTimersByTimeAsync(60);
+
+      await settled;
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+
+  it('enforces its own timeout when there is no caller signal at all', async () => {
+    vi.useFakeTimers();
+    try {
+      const inFlight = graphqlPost(opts(50), '{ ping }');
+      const settled = expect(inFlight).rejects.toThrow();
+
+      await vi.advanceTimersByTimeAsync(60);
+
+      await settled;
+    } finally {
+      vi.useRealTimers();
+    }
+  });
+});
+
+describe('buildOapOpts carries the caller through', () => {
+  const cfg = {
+    oap: { queryUrl: 'http://oap.test:12800', timeoutMs: 1234, auth: undefined },
+  } as unknown as HorizonConfig;
+
+  it('passes a signal when a read route supplies one', () => {
+    const ac = new AbortController();
+    expect(buildOapOpts(cfg, undefined, ac.signal).signal).toBe(ac.signal);
+  });
+
+  // Background timers and anything that MUTATES pass nothing: a closed tab must
+  // not be able to cancel a profiling task half-created, or a template push
+  // half-applied.
+  it('leaves it absent otherwise', () => {
+    expect(buildOapOpts(cfg).signal).toBeUndefined();
+  });
+});
diff --git a/apps/bff/src/client/graphql.ts b/apps/bff/src/client/graphql.ts
index b984ef99..273b0bba 100644
--- a/apps/bff/src/client/graphql.ts
+++ b/apps/bff/src/client/graphql.ts
@@ -27,6 +27,20 @@ export interface GraphqlOptions {
   /** Optional basic-auth credentials for outbound OAP calls. When
    *  set, the client adds an `Authorization: Basic <b64>` header. */
   auth?: { username: string; password: string };
+  /**
+   * The CALLER's cancellation — the browser going away, or a refresh round
+   * hitting its cap.
+   *
+   * Without it the BFF finished a read nobody was listening to any more: a
+   * refresh that gave up after a minute cancelled the browser's socket while
+   * the query it triggered carried on against OAP, and a page abandoned
+   * mid-load left its fan-out running to completion. On a cluster fan-out that
+   * is one abandoned request multiplied by the node count.
+   *
+   * Combined with — never replacing — this client's own timeout, so a slow OAP
+   * is still cut off at `timeoutMs` whether or not the caller supplied one.
+   */
+  signal?: AbortSignal;
 }
 
 /**
@@ -37,12 +51,16 @@ export interface GraphqlOptions {
 export function buildOapOpts(
   cfg: HorizonConfig,
   fetch?: FetchLike,
+  /** The caller's cancellation. READ routes pass the client's; background
+   *  timers and anything that mutates pass nothing — see `GraphqlOptions`. */
+  signal?: AbortSignal,
 ): GraphqlOptions {
   return {
     queryUrl: cfg.oap.queryUrl,
     timeoutMs: cfg.oap.timeoutMs,
     auth: cfg.oap.auth,
     fetch,
+    ...(signal ? { signal } : {}),
   };
 }
 
@@ -85,6 +103,12 @@ export async function graphqlPost<T>(
   const url = opts.queryUrl.replace(/\/$/, '') + '/graphql';
   const controller = new AbortController();
   const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
+  // EITHER ends the request: this client's own timeout, or the caller giving
+  // up. Combined rather than chosen between — a caller's signal must not
+  // remove the timeout that protects against a slow OAP.
+  const signal = opts.signal
+    ? AbortSignal.any([controller.signal, opts.signal])
+    : controller.signal;
   const headers: Record<string, string> = { 'content-type': 'application/json' };
   if (opts.auth) {
     headers.authorization = basicAuthHeader(opts.auth.username, opts.auth.password);
@@ -95,7 +119,7 @@ export async function graphqlPost<T>(
       method: 'POST',
       headers,
       body: JSON.stringify({ query, variables: variables ?? {} }),
-      signal: controller.signal,
+      signal,
     });
   } finally {
     clearTimeout(timer);
diff --git a/apps/bff/src/http/client-gone.test.ts b/apps/bff/src/http/client-gone.test.ts
new file mode 100644
index 00000000..97bc96f3
--- /dev/null
+++ b/apps/bff/src/http/client-gone.test.ts
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * "The client has stopped listening" has to mean exactly that.
+ *
+ * Both errors are silent. Too eager, and a successful reply cancels the work
+ * that produced it — the response is already on the wire, so what breaks is
+ * whatever was still finishing behind it. Too reluctant, and abandoned reads
+ * cost OAP just as much as watched ones, which is the whole reason for this.
+ *
+ * Driven over a real socket rather than `app.inject`, because the thing under
+ * test IS the socket closing: injection never opens one, so it could not tell
+ * the two cases apart.
+ */
+
+import { describe, expect, it, afterEach } from 'vitest';
+import Fastify, { type FastifyInstance } from 'fastify';
+import { clientGone } from './client-gone.js';
+
+let app: FastifyInstance | null = null;
+afterEach(async () => {
+  await app?.close();
+  app = null;
+});
+
+/** A server whose handler reports what happened to its signal.
+ *
+ * `aborted` counts a signal that arrived ALREADY aborted, not just one that
+ * fires later: `addEventListener('abort')` never runs on an aborted signal, so
+ * a watcher built only on the event reports the most damaging case — every read
+ * cancelled before it started — as "never aborted". */
+async function serve(): Promise<{ url: string; aborted: () => boolean | null }> {
+  let seen: boolean | null = null;
+  const watch = (reply: Parameters<typeof clientGone>[0]): void => {
+    const signal = clientGone(reply);
+    seen = signal.aborted;
+    signal.addEventListener('abort', () => {
+      seen = true;
+    });
+  };
+  app = Fastify();
+  app.get('/slow', async (_req, reply) => {
+    watch(reply);
+    // Long enough that the caller can give up first.
+    await new Promise((r) => setTimeout(r, 300));
+    return reply.send({ ok: true });
+  });
+  app.get('/fast', async (_req, reply) => {
+    watch(reply);
+    return reply.send({ ok: true });
+  });
+  // Behind an async preHandler, as every read route is: `requireAuth` awaits,
+  // and it is during that await that Node finishes and destroys the request's
+  // readable. A route whose handler runs synchronously never sees it.
+  app.post(
+    '/body',
+    { preHandler: async () => { await new Promise((r) => setImmediate(r)); } },
+    async (_req, reply) => {
+      watch(reply);
+      return reply.send({ ok: true });
+    },
+  );
+  await app.listen({ port: 0, host: '127.0.0.1' });
+  const addr = app.server.address();
+  const port = typeof addr === 'object' && addr ? addr.port : 0;
+  return { url: `http://127.0.0.1:${port}`, aborted: () => seen };
+}
+
+describe('clientGone', () => {
+  it('fires when the caller hangs up mid-request', async () => {
+    const { url, aborted } = await serve();
+    const ac = new AbortController();
+
+    const req = fetch(`${url}/slow`, { signal: ac.signal }).catch(() => undefined);
+    await new Promise((r) => setTimeout(r, 50));
+    ac.abort();
+    await req;
+    await new Promise((r) => setTimeout(r, 350));
+
+    expect(aborted(), 'an abandoned read went on costing OAP work').toBe(true);
+  });
+
+  it('does NOT fire when the reply completed normally', async () => {
+    const { url, aborted } = await serve();
+
+    const res = await fetch(`${url}/fast`);
+    await res.json();
+    await new Promise((r) => setTimeout(r, 100));
+
+    expect(aborted(), 'a successful reply cancelled its own work').toBe(false);
+  });
+
+  it('does not fire on a POST whose body the route consumed', async () => {
+    const { url, aborted } = await serve();
+
+    const res = await fetch(`${url}/body`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ topN: 20 }),
+    });
+    await res.json();
+
+    // Reading the body ENDS the request stream, and Node destroys a readable it
+    // has ended. Asking the request whether it was destroyed therefore answered
+    // "yes" in every handler, and every read cancelled itself before issuing a
+    // single query — while the reply still went out as a well-formed 200 saying
+    // OAP could not be reached.
+    expect(aborted(), 'a live POST was cancelled before it started').toBe(false);
+  });
+
+  it('does not fire on a slow reply the caller waited for', async () => {
+    const { url, aborted } = await serve();
+
+    const res = await fetch(`${url}/slow`);
+    await res.json();
+    await new Promise((r) => setTimeout(r, 100));
+
+    expect(aborted()).toBe(false);
+  });
+});
diff --git a/apps/bff/src/http/client-gone.ts b/apps/bff/src/http/client-gone.ts
new file mode 100644
index 00000000..2b97981d
--- /dev/null
+++ b/apps/bff/src/http/client-gone.ts
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { FastifyReply } from 'fastify';
+
+/**
+ * A signal that fires when the client has stopped listening.
+ *
+ * Read routes hand this to `buildOapOpts` so an abandoned request stops costing
+ * OAP work. Without it the BFF ran every read to completion regardless: a
+ * refresh round that gave up after its cap cancelled the browser's socket while
+ * the queries it started carried on upstream, and a page abandoned mid-load
+ * left its whole fan-out running. On a cluster fan-out that is one abandoned
+ * request multiplied by the node count.
+ *
+ * **Reads only.** A request that MUTATES — creating a profiling task, pushing a
+ * template — must run to completion even if the operator navigates away, or a
+ * closed tab could leave a half-applied change with nobody watching. Those
+ * routes deliberately pass nothing.
+ *
+ * The reply's `close` is what says the client went away, and only when the
+ * response had not finished: `close` also fires on every successful reply, and
+ * aborting then would cancel work that had already succeeded.
+ */
+export function clientGone(reply: FastifyReply): AbortSignal {
+  const ac = new AbortController();
+  // Already gone. A route that awaits something before reaching here — a
+  // template resolution on a cold cache, say — can be called after the client
+  // has hung up, and `close` has then already fired with nobody listening. The
+  // work would run to completion for a socket that no longer exists.
+  //
+  // Asked of the RESPONSE only. The request's readable is destroyed as soon as
+  // its body has been read — Node auto-destroys it on `end` — so it reads as
+  // destroyed in every handler, for a GET as much as a POST, and says nothing
+  // about whether anyone is still listening.
+  if (reply.raw.destroyed && !reply.raw.writableFinished) {
+    ac.abort();
+    return ac.signal;
+  }
+  reply.raw.on('close', () => {
+    if (!reply.raw.writableFinished) ac.abort();
+  });
+  return ac.signal;
+}
diff --git a/apps/bff/src/http/query/alarms.ts b/apps/bff/src/http/query/alarms.ts
index 802abd2d..bcf6cf1a 100644
--- a/apps/bff/src/http/query/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -62,6 +62,7 @@ import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import { badRequest } from '../../errors.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import { getOapCapabilities } from '../../logic/oap/capabilities.js';
 import { readPageWith, type OapPaging } from '../../logic/paging/read-page.js';
 import { withColdStage } from '../../util/duration.js';
@@ -338,10 +339,11 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
         ? buildEntity(q, { name: q.service, normal: q.normal })
         : null;
 
-    const opts = buildOapOpts(deps.config.current, deps.fetch);
+    const signal = clientGone(reply);
+    const opts = buildOapOpts(deps.config.current, deps.fetch, signal);
     const [offset, caps, catalog] = await Promise.all([
-      getServerOffsetMinutes(deps.config, deps.fetch),
-      getOapCapabilities(deps.config.current, deps.fetch),
+      getServerOffsetMinutes(deps.config, deps.fetch, signal),
+      getOapCapabilities(deps.config.current, deps.fetch, signal),
       serviceLayer.get(),
     ]);
     const start = fmtSecond(q.startTime, offset);
@@ -420,10 +422,11 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
         return badRequest(`window exceeds ${WINDOW_CAP_MS / 60_000}m cap`);
       }
 
-      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(deps.config.current, deps.fetch, signal);
       const [offset, caps] = await Promise.all([
-        getServerOffsetMinutes(deps.config, deps.fetch),
-        getOapCapabilities(deps.config.current, deps.fetch),
+        getServerOffsetMinutes(deps.config, deps.fetch, signal),
+        getOapCapabilities(deps.config.current, deps.fetch, signal),
       ]);
       const start = fmtSecond(q.startTime, offset);
       const end = fmtSecond(q.endTime, offset);
@@ -502,7 +505,8 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
       if (!layer || typeof layer !== 'string') {
         return reply.code(400).send({ error: 'missing_layer' });
       }
-      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(deps.config.current, deps.fetch, signal);
       try {
         const got = await graphqlPost<ListServicesRaw>(opts, LIST_SERVICES_QUERY, { layer });
         const services = (got.listServices ?? [])
diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts
index 94c0eae1..d10f7afb 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -41,6 +41,7 @@ import type {
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import { withColdStage } from '../../util/duration.js';
 import {
   defaultMinuteWindow,
@@ -153,11 +154,12 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
       let serviceId = '';
       let normal = true;
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
       // Probe OAP's timezone so the Duration strings we emit match
       // OAP-server local time (not UTC, not browser-local). Cached 60s
       // inside getServerOffsetMinutes; ~one OAP round-trip per minute.
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       // Honor the SPA's time picker (step + start/end). Falls back to
       // the last-hour MINUTE default when the caller omits the range.
       const window =
diff --git a/apps/bff/src/http/query/deployment.ts b/apps/bff/src/http/query/deployment.ts
index 0e9966a4..20cf1f86 100644
--- a/apps/bff/src/http/query/deployment.ts
+++ b/apps/bff/src/http/query/deployment.ts
@@ -31,6 +31,7 @@ import type { FetchLike, DeploymentConfig, UITemplateClient } from '@skywalking-
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import {
   defaultMinuteWindow,
   getServerOffsetMinutes,
@@ -39,7 +40,7 @@ import {
   type Window,
 } from '../../util/window.js';
 import { deploymentConfigFor } from '../../logic/layers/loader.js';
-import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { blockedReason, resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { parsePreviewDeployment } from '../../logic/layers/preview.js';
 import { buildDeployment, emptyDeploymentResponse } from '../../logic/oap/deployment.js';
 import { serviceScopeOf } from '../../logic/oap/service-scope.js';
@@ -98,9 +99,10 @@ export function registerDeploymentRoute(
           // — block (like the service-topology route) instead of a
           // misleading "not supported" 404. The SPA's connectivity banner
           // explains the empty state.
-          return reply.send(
-            emptyDeploymentResponse(layerKey, serviceId, { nodeMetrics: [] }, false),
-          );
+          return reply.send({
+            ...emptyDeploymentResponse(layerKey, serviceId, { nodeMetrics: [] }, false),
+            ...blockedReason(eff.reason),
+          });
         }
         cfg = deploymentConfigFor(eff.template);
       }
@@ -109,8 +111,9 @@ export function registerDeploymentRoute(
       }
 
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       // Honor the SPA's topbar picker triplet; else fall back to the
       // last-hour MINUTE window (dashboards family — minute precision).
       const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
diff --git a/apps/bff/src/http/query/endpoint-dependency.ts b/apps/bff/src/http/query/endpoint-dependency.ts
index 7e89439f..e0a59a70 100644
--- a/apps/bff/src/http/query/endpoint-dependency.ts
+++ b/apps/bff/src/http/query/endpoint-dependency.ts
@@ -33,6 +33,7 @@ import type { EndpointDependencyConfig, FetchLike, UITemplateClient } from '@sky
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import {
   defaultMinuteWindow,
   getServerOffsetMinutes,
@@ -41,7 +42,7 @@ import {
   type Window,
 } from '../../util/window.js';
 import { endpointDependencyConfigFor } from '../../logic/layers/loader.js';
-import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { blockedReason, resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { parsePreviewEndpointDep } from '../../logic/layers/preview.js';
 import { buildEndpointDependency, emptyEndpointDependencyResponse } from '../../logic/oap/endpoint-dependency.js';
 import { serviceNormalOf, serviceScopeOf } from '../../logic/oap/service-scope.js';
@@ -109,14 +110,21 @@ export function registerEndpointDependencyRoute(
         const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
         if (eff.blocked) {
           // Template store unreachable / layer disabled — block, no defaults.
-          return reply.send(emptyEndpointDependencyResponse(layerKey, service.name, endpointArg, null, { nodeMetrics: [] }, true));
+          // `reachable: false` for the same reason as the service-topology
+          // route: an empty body here is a failure to read, and a caller must
+          // be able to tell it from a graph that is legitimately empty.
+          return reply.send({
+            ...emptyEndpointDependencyResponse(layerKey, service.name, endpointArg, null, { nodeMetrics: [] }, false),
+            ...blockedReason(eff.reason),
+          });
         }
         epCfg = endpointDependencyConfigFor(eff.template);
       }
 
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       // Honor the SPA's topbar picker triplet; else fall back to the
       // last-hour MINUTE window (dashboards family — minute precision).
       const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
diff --git a/apps/bff/src/http/query/endpoint.ts b/apps/bff/src/http/query/endpoint.ts
index e4e39207..dd425bad 100644
--- a/apps/bff/src/http/query/endpoint.ts
+++ b/apps/bff/src/http/query/endpoint.ts
@@ -37,6 +37,7 @@ import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import { serviceScopeOf } from '../../logic/oap/service-scope.js';
 import { overFetchSize, takeOverFetched } from '../../logic/paging/read-page.js';
 import { withColdStage } from '../../util/duration.js';
@@ -106,8 +107,9 @@ export function registerEndpointRoute(app: FastifyInstance, deps: EndpointRouteD
       const limit = Math.max(20, Math.min(50, Number(q.limit) || 20));
 
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       const window = defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
 
       try {
diff --git a/apps/bff/src/http/query/instance-topology.ts b/apps/bff/src/http/query/instance-topology.ts
index 239374d6..1e8f89a1 100644
--- a/apps/bff/src/http/query/instance-topology.ts
+++ b/apps/bff/src/http/query/instance-topology.ts
@@ -32,6 +32,7 @@ import type { FetchLike, InstanceTopologyConfig, UITemplateClient } from '@skywa
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import {
   defaultMinuteWindow,
   getServerOffsetMinutes,
@@ -40,7 +41,7 @@ import {
   type Window,
 } from '../../util/window.js';
 import { instanceTopologyConfigFor } from '../../logic/layers/loader.js';
-import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { blockedReason, resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { parsePreviewTopology } from '../../logic/layers/preview.js';
 import { buildInstanceTopology, emptyInstanceTopologyResponse } from '../../logic/oap/instance-topology.js';
 
@@ -97,15 +98,16 @@ export function registerInstanceTopologyRoute(
           // of collapsing to a misleading "not supported" 404. Serve an
           // empty unreachable response so the SPA's connectivity banner
           // explains the empty state.
-          return reply.send(
-            emptyInstanceTopologyResponse(
+          return reply.send({
+            ...emptyInstanceTopologyResponse(
               layerKey,
               clientServiceId,
               serverServiceId,
               { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] },
               false,
             ),
-          );
+            ...blockedReason(eff.reason),
+          });
         }
         instCfg = instanceTopologyConfigFor(eff.template);
       }
@@ -114,8 +116,9 @@ export function registerInstanceTopologyRoute(
       }
 
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       // Honor the SPA's topbar picker triplet; else fall back to the
       // last-hour MINUTE window (dashboards family — minute precision).
       const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
diff --git a/apps/bff/src/http/query/instance.ts b/apps/bff/src/http/query/instance.ts
index 99338340..961df4bd 100644
--- a/apps/bff/src/http/query/instance.ts
+++ b/apps/bff/src/http/query/instance.ts
@@ -34,6 +34,7 @@ import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import { serviceScopeOf } from '../../logic/oap/service-scope.js';
 import { withColdStage } from '../../util/duration.js';
 import { defaultMinuteWindow, getServerOffsetMinutes } from '../../util/window.js';
@@ -103,8 +104,9 @@ export function registerInstanceRoute(app: FastifyInstance, deps: InstanceRouteD
       // The handle the caller sent, echoed back on every reply below.
       const serviceArg = scope.service.name || serviceId;
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       const window = defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
       try {
         const data = await graphqlPost<{ instances: OapInstance[] }>(opts, LIST_INSTANCES, {
diff --git a/apps/bff/src/http/query/landing.ts b/apps/bff/src/http/query/landing.ts
index baa06b8a..fb13cd2b 100644
--- a/apps/bff/src/http/query/landing.ts
+++ b/apps/bff/src/http/query/landing.ts
@@ -45,6 +45,7 @@ import type {
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import { expressionForServiceMetricSeries } from '../../util/mqe-catalog.js';
 import {
   defaultMinuteWindow,
@@ -288,8 +289,9 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
       const cfgCurrent = deps.config.current;
       const { bulkSize: maxServicesPerBatch, concurrency: batchConcurrency } =
         cfgCurrent.performance.bulk.landing;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       // Honor the SPA's topbar time picker when all three triplet fields
       // are present; otherwise fall back to the last-hour MINUTE window
       // (legacy callers + the BFF's own service-count probes).
diff --git a/apps/bff/src/http/query/services.ts b/apps/bff/src/http/query/services.ts
index 038750db..52f5b468 100644
--- a/apps/bff/src/http/query/services.ts
+++ b/apps/bff/src/http/query/services.ts
@@ -66,7 +66,10 @@ export function registerLayerServicesRoute(
         const all = snap.byLayer.get(layerUpper) ?? [];
         const rows = group === undefined ? all : all.filter((r) => r.group === group);
         return reply.send({
-          reachable: true,
+          // Honest about the read that produced this. An empty roster from a
+          // failed catalog read is not a layer with no services, and saying it
+          // was let the picker empty itself during an outage.
+          reachable: snap.unreachable !== true,
           layer: layerUpper,
           // `group` is OAP's `Service.group` — the `<group>::` prefix, empty
           // when the service has none. Callers use it to section a long roster.
diff --git a/apps/bff/src/http/query/topology.ts b/apps/bff/src/http/query/topology.ts
index 0c6c8995..d1594c33 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -35,6 +35,7 @@ import type { FetchLike, TopologyConfig, UITemplateClient } from '@skywalking-ho
 import type { AuthDeps } from '../../user/middleware.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts } from '../../client/graphql.js';
+import { clientGone } from '../client-gone.js';
 import {
   defaultMinuteWindow,
   getServerOffsetMinutes,
@@ -43,7 +44,7 @@ import {
   type Window,
 } from '../../util/window.js';
 import { topologyConfigFor } from '../../logic/layers/loader.js';
-import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
+import { blockedReason, resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { parsePreviewTopology } from '../../logic/layers/preview.js';
 import { getServiceHierarchy } from '../../logic/oap/hierarchy.js';
 import { buildServiceTopology, emptyTopologyResponse } from '../../logic/oap/service-topology.js';
@@ -101,19 +102,31 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
         const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
         if (eff.blocked) {
           // Template store unreachable or this layer's template disabled —
-          // block: serve no map and no in-code default config. The SPA's
-          // connectivity banner explains the empty state (unreachable); a
-          // disabled layer is hidden from the sidebar so it isn't reached.
-          return reply.send(
-            emptyTopologyResponse(layerKey, serviceArg, depth, { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] }, true),
-          );
+          // block: serve no map and no in-code default config. A disabled
+          // layer is hidden from the sidebar so it isn't reached.
+          //
+          // `reachable: false`, like every other graph route: the empty body
+          // is a FAILURE to read, not a map that happens to have no nodes,
+          // and a client that cannot tell those apart will replace a good
+          // graph with this one. The four routes disagreed here — two sent
+          // `true` for this exact condition — which made the flag unusable
+          // as the signal for "is this response worth drawing".
+          return reply.send({
+            ...emptyTopologyResponse(layerKey, serviceArg, depth, { nodeMetrics: [], linkServerMetrics: [], linkClientMetrics: [] }, false),
+            // WHICH block, so the page can say something an operator can act
+            // on. A store to go and fix and a template an administrator
+            // switched off need opposite responses, and one sentence for both
+            // sends people looking in the wrong place.
+            ...blockedReason(eff.reason),
+          });
         }
         topoCfg = topologyConfigFor(eff.template);
       }
 
       const cfgCurrent = deps.config.current;
-      const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
+      const signal = clientGone(reply);
+      const opts = buildOapOpts(cfgCurrent, deps.fetch, signal);
+      const offset = await getServerOffsetMinutes(deps.config, deps.fetch, signal);
       // Honor the SPA's topbar time picker when all three triplet
       // query-params are present; otherwise fall back to the last-hour
       // MINUTE window. The Overview "topology" widget + per-layer
diff --git a/apps/bff/src/logic/layers/effective.test.ts b/apps/bff/src/logic/layers/effective.test.ts
index d0cdd14a..879c8e3c 100644
--- a/apps/bff/src/logic/layers/effective.test.ts
+++ b/apps/bff/src/logic/layers/effective.test.ts
@@ -30,7 +30,7 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import type { UITemplateClient, UITemplateRow } from '@skywalking-horizon-ui/api-client';
-import { invalidateSyncCache } from '../templates/sync.js';
+import { invalidateSyncCache, resetTemplateSyncState, templateStoreStatus } from '../templates/sync.js';
 import { iterateBundledTemplates } from '../templates/aggregator.js';
 import { buildEnvelope, serializeEnvelope } from '../templates/names.js';
 import { logger } from '../../logger.js';
@@ -78,11 +78,11 @@ function bundledLayerContent(key: string): unknown {
 }
 
 beforeEach(() => {
-  invalidateSyncCache();
+  resetTemplateSyncState();
   vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
 });
 afterEach(() => {
-  invalidateSyncCache();
+  resetTemplateSyncState();
   vi.restoreAllMocks();
 });
 
@@ -185,3 +185,174 @@ describe('resolveEffectiveLayer — no template client wired', () => {
     });
   });
 });
+
+/**
+ * A store that CAN'T be read is not the same as a store that never could.
+ *
+ * Blocking exists so an operator whose published dashboards cannot be read is
+ * not shown the shipped defaults dressed up as their configuration. That
+ * reasoning is about the DISK BUNDLE, and it does not reach the rows Horizon
+ * read from OAP a minute ago — those are the operator's own work, merely stale.
+ * Throwing them away turned a transient admin-port blip into every dashboard,
+ * overview and map going blank at once.
+ */
+describe('resolveEffectiveLayer — a store that was readable before', () => {
+  /** A client whose store can change between calls, so a test can take the
+   *  admin port away after a successful read. */
+  function flippingClient(store: { current: Store }): () => UITemplateClient {
+    return () =>
+      ({
+        list: async (): Promise<UITemplateRow[]> => {
+          if (store.current === 'unreachable') {
+            throw new Error('HTTP 404 on /ui-management/templates');
+          }
+          return store.current.map((r) => ({ ...r }));
+        },
+        create: () => Promise.reject(new Error('must not write')),
+        update: () => Promise.reject(new Error('must not write')),
+        disable: () => Promise.reject(new Error('must not write')),
+      }) as unknown as UITemplateClient;
+  }
+
+  const published = { key: 'GENERAL', alias: 'Edited by the operator' };
+
+  it('keeps serving the last read rows when the store goes away', async () => {
+    const store: { current: Store } = { current: [layerRow('GENERAL', published)] };
+    const client = flippingClient(store);
+    expect(await resolveEffectiveLayer(client, 'GENERAL')).toMatchObject({ reason: 'ok' });
+
+    store.current = 'unreachable';
+    invalidateSyncCache(); // only the 30s window; retention must survive it
+    const after = await resolveEffectiveLayer(client, 'GENERAL');
+
+    expect(after.blocked, 'a readable-before store blanked every dashboard').toBe(false);
+    expect(after.template, 'the operator’s own published template was discarded').toMatchObject({
+      alias: published.alias,
+    });
+  });
+
+  it('still refuses the DISK BUNDLE — retention is remote rows, not defaults', async () => {
+    // GENERAL is bundled, and the bundled copy differs from what is published.
+    expect(bundledLayerContent('GENERAL')).toBeTruthy();
+    const store: { current: Store } = { current: [layerRow('GENERAL', published)] };
+    const client = flippingClient(store);
+    await resolveEffectiveLayer(client, 'GENERAL');
+
+    store.current = 'unreachable';
+    invalidateSyncCache();
+    const after = await resolveEffectiveLayer(client, 'GENERAL');
+
+    expect(after.template).toMatchObject({ alias: published.alias });
+    expect(
+      JSON.stringify(after.template),
+      'the bundled copy was served as though it were the live config',
+    ).not.toBe(JSON.stringify(bundledLayerContent('GENERAL')));
+  });
+
+  it('blocks a layer it never read, even while retaining another', async () => {
+    const store: { current: Store } = { current: [layerRow('GENERAL', published)] };
+    const client = flippingClient(store);
+    await resolveEffectiveLayer(client, 'GENERAL');
+
+    store.current = 'unreachable';
+    invalidateSyncCache();
+
+    expect(await resolveEffectiveLayer(client, 'NOT_A_BUNDLED_LAYER')).toEqual({
+      template: null,
+      blocked: true,
+      reason: 'store-unreachable',
+    });
+  });
+
+  it('keeps a layer DISABLED while unreachable — that decision is an answer', async () => {
+    const store: { current: Store } = { current: [layerRow('GENERAL', published, true)] };
+    const client = flippingClient(store);
+    expect(await resolveEffectiveLayer(client, 'GENERAL')).toMatchObject({
+      blocked: true,
+      reason: 'layer-disabled',
+    });
+
+    store.current = 'unreachable';
+    invalidateSyncCache();
+
+    expect(
+      await resolveEffectiveLayer(client, 'GENERAL'),
+      'an administrator’s decision stopped applying because the store blipped',
+    ).toMatchObject({ blocked: true, reason: 'layer-disabled' });
+  });
+});
+
+/**
+ * What the Cluster Status page is told.
+ *
+ * A pure read of what the sync module already knows — it must never probe OAP,
+ * or a status page would add load every time it rendered. And it has to say
+ * more than "unreachable": that one word covers a 404 from a module nobody
+ * enabled, a 401 from wrong credentials, and a timeout from a slow admin port
+ * — three different things to go and fix.
+ */
+describe('templateStoreStatus — what the operator is shown', () => {
+  function flipping(store: { current: Store }): () => UITemplateClient {
+    return () =>
+      ({
+        list: async (): Promise<UITemplateRow[]> => {
+          if (store.current === 'unreachable') throw new Error('HTTP 401 on /ui-management/templates');
+          return store.current.map((r) => ({ ...r }));
+        },
+        create: () => Promise.reject(new Error('must not write')),
+        update: () => Promise.reject(new Error('must not write')),
+        disable: () => Promise.reject(new Error('must not write')),
+      }) as unknown as UITemplateClient;
+  }
+
+  it('reports nothing loaded before the first read', () => {
+    const s = templateStoreStatus();
+    expect(s.lastSuccessfulSyncAt).toBeNull();
+    expect(s.counts.layer).toBe(0);
+    expect(s.servingRetained).toBe(false);
+  });
+
+  it('counts what is being served from OAP, and when it was read', async () => {
+    const store: { current: Store } = {
+      current: [layerRow('GENERAL', { key: 'GENERAL' }), layerRow('MESH', { key: 'MESH' })],
+    };
+    await resolveEffectiveLayer(flipping(store), 'GENERAL');
+
+    const s = templateStoreStatus();
+    expect(s.counts.layer, 'the count is not what OAP actually served').toBe(2);
+    expect(s.lastSuccessfulSyncAt).not.toBeNull();
+    expect(s.unreachable).toBe(false);
+    expect(s.lastError).toBeNull();
+  });
+
+  it('keeps the failure’s own words, and says it is still serving', async () => {
+    const store: { current: Store } = { current: [layerRow('GENERAL', { key: 'GENERAL' })] };
+    const client = flipping(store);
+    await resolveEffectiveLayer(client, 'GENERAL');
+
+    store.current = 'unreachable';
+    invalidateSyncCache();
+    await resolveEffectiveLayer(client, 'GENERAL');
+
+    const s = templateStoreStatus();
+    expect(s.unreachable).toBe(true);
+    expect(s.servingRetained, 'the page cannot tell a warning from an outage').toBe(true);
+    expect(s.lastError?.message, 'only "unreachable" reached the page').toContain('401');
+    expect(s.counts.layer, 'the counts stopped describing what is on screen').toBe(1);
+  });
+
+  it('forgets the error once a read succeeds again', async () => {
+    const store: { current: Store } = { current: 'unreachable' };
+    const client = flipping(store);
+    await resolveEffectiveLayer(client, 'GENERAL');
+    expect(templateStoreStatus().lastError).not.toBeNull();
+
+    store.current = [layerRow('GENERAL', { key: 'GENERAL' })];
+    invalidateSyncCache();
+    await resolveEffectiveLayer(client, 'GENERAL');
+
+    const s = templateStoreStatus();
+    expect(s.lastError, 'an old failure was still being reported as current').toBeNull();
+    expect(s.unreachable).toBe(false);
+  });
+});
diff --git a/apps/bff/src/logic/layers/effective.ts b/apps/bff/src/logic/layers/effective.ts
index 069b196b..f15240a4 100644
--- a/apps/bff/src/logic/layers/effective.ts
+++ b/apps/bff/src/logic/layers/effective.ts
@@ -108,29 +108,38 @@ export async function resolveEffectiveLayer(
       bundled: () => iterateBundledTemplates(),
       logger,
     });
-    // Store unreachable → block. NOT a degrade to disk: the bundle can differ
-    // from what the operator published, and rendering it as though it were the
-    // live config is worse than an empty page behind the connectivity banner.
-    // An OAP that never serves the template store (10.x) runs
-    // `templates.mode: readonly`, where `unreachable` is always false.
-    if (sync.unreachable) return { template: null, blocked: true, reason: 'store-unreachable' };
     const name = formatName('layer', canonicalLayerKey(layerKey));
     const row = sync.rows.find(
       (r) => r.name === name && r.kind === 'layer' && r.locale === undefined,
     );
-    // No remote row → reachable but unknown/unsynced layer → in-code defaults.
-    if (!row) return { template: null, blocked: false, reason: 'no-remote-row' };
     // Admin disabled the layer's template → block (the sidebar also hides it).
-    if (row.status === 'disabled') return { template: null, blocked: true, reason: 'layer-disabled' };
-    // The row's own name got us here, but the name alone is not the identity:
-    // `effective` is what the shared identity rule leaves behind, so a row
-    // carrying another layer's template never renders as this one.
-    if (row.effective === 'remote' && row.remote) {
+    // Checked BEFORE the unreachable branch: a disabled layer is an
+    // administrator's decision and stays decided whether or not the store can
+    // be read right now.
+    if (row?.status === 'disabled') return { template: null, blocked: true, reason: 'layer-disabled' };
+    // A REMOTE row renders — including one retained from the last successful
+    // read while the store is unreachable. That is the operator's own
+    // published configuration, not the disk bundle: `sync` marks bundled rows
+    // `bundled-fallback`, never `remote`, so this gate cannot admit them and
+    // the live-mode rule is untouched.
+    if (row?.effective === 'remote' && row.remote) {
+      // The row's own name got us here, but the name alone is not the
+      // identity: `effective` is what the shared identity rule leaves behind,
+      // so a row carrying another layer's template never renders as this one.
       const env = parseEnvelope(row.remote.configuration);
       if (env?.content && typeof env.content === 'object' && 'key' in env.content) {
         return { template: env.content as LayerTemplate, blocked: false, reason: 'ok' };
       }
     }
+    // Nothing retained for this layer and the store cannot be read → block.
+    // NOT a degrade to disk: the bundle can differ from what the operator
+    // published, and rendering it as though it were the live config is worse
+    // than an empty page behind the connectivity banner. An OAP that never
+    // serves the template store (10.x) runs `templates.mode: readonly`, where
+    // `unreachable` is always false.
+    if (sync.unreachable) return { template: null, blocked: true, reason: 'store-unreachable' };
+    // No remote row → reachable but unknown/unsynced layer → in-code defaults.
+    if (!row) return { template: null, blocked: false, reason: 'no-remote-row' };
     // Bundled-fallback (seed didn't land), identity-invalid, or unparseable
     // remote → we do NOT resurrect bundled at render time; in-code defaults.
     return { template: null, blocked: false, reason: 'no-remote-row' };
@@ -152,3 +161,18 @@ export async function resolveEffectiveLayerTemplate(
 ): Promise<LayerTemplate | null> {
   return (await resolveEffectiveLayer(uiTemplateClient, layerKey)).template;
 }
+
+/**
+ * The blocked reason as the graph routes report it.
+ *
+ * Only two of the resolver's reasons BLOCK, and only those two are worth
+ * naming on the wire: a store that could not be read, and a template an
+ * administrator disabled. Anything else is not a block, so it contributes
+ * nothing and the field stays absent.
+ */
+export function blockedReason(
+  reason: EffectiveLayerReason,
+): { blocked?: 'store-unreachable' | 'layer-disabled' } {
+  if (reason === 'store-unreachable' || reason === 'layer-disabled') return { blocked: reason };
+  return {};
+}
diff --git a/apps/bff/src/logic/oap/capabilities.test.ts b/apps/bff/src/logic/oap/capabilities.test.ts
index 34800963..8726ccbd 100644
--- a/apps/bff/src/logic/oap/capabilities.test.ts
+++ b/apps/bff/src/logic/oap/capabilities.test.ts
@@ -86,6 +86,26 @@ describe('OAP capability probe', () => {
     });
   });
 
+  it('does NOT cache the conservative answer from a probe the caller cancelled', async () => {
+    const ac = new AbortController();
+    // What a real fetch does once the caller's signal fires.
+    const aborting: FetchLike = async () => {
+      ac.abort();
+      const err = new Error('This operation was aborted');
+      err.name = 'AbortError';
+      throw err;
+    };
+    // Nothing was measured, so nothing may be concluded. Caching all-false here
+    // would hide alarm queries and content search from every LATER request for
+    // a minute, on the strength of a probe that was never made.
+    await expect(getOapCapabilities(config, aborting, ac.signal)).rejects.toThrow();
+    const oap = fakeOap(['queryAlarms', 'supportQueryLogsByKeywords'], true);
+    await expect(getOapCapabilities(config, oap.fetch)).resolves.toEqual({
+      queryAlarms: true,
+      logKeywords: true,
+    });
+  });
+
   it('re-probes soon after a FAILED keyword read, not in five minutes', async () => {
     // A false that came from a timeout must not hide content search for the
     // whole success TTL — one slow reply would cost an ElasticSearch operator
diff --git a/apps/bff/src/logic/oap/capabilities.ts b/apps/bff/src/logic/oap/capabilities.ts
index ef346815..6d6f97a6 100644
--- a/apps/bff/src/logic/oap/capabilities.ts
+++ b/apps/bff/src/logic/oap/capabilities.ts
@@ -89,6 +89,10 @@ export function _resetCapabilitiesCache(): void {
 export async function getOapCapabilities(
   config: HorizonConfig,
   fetchImpl?: FetchLike,
+  /** The caller's cancellation, on a read route. See `getServerOffsetMinutes`:
+   *  the same reasoning applies, and the conservative result this caches on
+   *  failure lasts a minute rather than expiring with the request. */
+  signal?: AbortSignal,
 ): Promise<OapCapabilities> {
   const key = config.oap.queryUrl;
   const now = Date.now();
@@ -97,8 +101,15 @@ export async function getOapCapabilities(
 
   let raw: IntrospectionRaw;
   try {
-    raw = await graphqlPost<IntrospectionRaw>(buildOapOpts(config, fetchImpl), INTROSPECTION_QUERY);
-  } catch {
+    raw = await graphqlPost<IntrospectionRaw>(
+      buildOapOpts(config, fetchImpl, signal),
+      INTROSPECTION_QUERY,
+    );
+  } catch (err) {
+    // Cancelled by the caller, so it measured nothing. Caching the conservative
+    // answer here would hide alarm queries and content search from every LATER
+    // request for a minute, on the strength of a probe that was never made.
+    if (signal?.aborted) throw err;
     const conservative: OapCapabilities = { queryAlarms: false, logKeywords: false };
     cache.set(key, { result: conservative, fetchedAt: now - CAPS_TTL_MS + CAPS_FAILURE_TTL_MS });
     return conservative;
@@ -115,11 +126,12 @@ export async function getOapCapabilities(
   if (fieldSet.has('supportQueryLogsByKeywords')) {
     try {
       const env = await graphqlPost<LogKeywordsRaw>(
-        buildOapOpts(config, fetchImpl),
+        buildOapOpts(config, fetchImpl, signal),
         LOG_KEYWORDS_QUERY,
       );
       logKeywords = env.supportQueryLogsByKeywords === true;
-    } catch {
+    } catch (err) {
+      if (signal?.aborted) throw err;
       logKeywords = false;
       probeFailed = true;
     }
diff --git a/apps/bff/src/logic/preflight/preflight.ts b/apps/bff/src/logic/preflight/preflight.ts
index 8247949d..1387ccf3 100644
--- a/apps/bff/src/logic/preflight/preflight.ts
+++ b/apps/bff/src/logic/preflight/preflight.ts
@@ -42,6 +42,7 @@ import type {
   PreflightModule,
   PreflightResult,
 } from '@skywalking-horizon-ui/api-client';
+import { templateStoreStatus } from '../templates/sync.js';
 import type { HorizonConfig } from '../../config/schema.js';
 
 export type { PreflightModule, PreflightResult };
@@ -115,6 +116,10 @@ export async function runPreflight(
       adminReachable: false,
       adminError: dump.error,
       templatesMode,
+      // Reported even here: when the admin host is down the store is
+      // unreachable BY DEFINITION, and what an operator needs to know is
+      // whether Horizon is still rendering what it read before.
+      templateStore: templateStoreStatus(),
       modules: REQUIRED_MODULES.map((m) => ({
         name: m.name,
         envVar: m.envVar,
@@ -160,6 +165,7 @@ export async function runPreflight(
     adminUrl,
     adminReachable: true,
     templatesMode,
+    templateStore: templateStoreStatus(),
     modules,
     dumpKeyCount: Object.keys(dump.body).length,
     generatedAt,
diff --git a/apps/bff/src/logic/services/service-layer-catalog.ts b/apps/bff/src/logic/services/service-layer-catalog.ts
index bf91deab..bea242be 100644
--- a/apps/bff/src/logic/services/service-layer-catalog.ts
+++ b/apps/bff/src/logic/services/service-layer-catalog.ts
@@ -61,6 +61,17 @@ export interface ServiceCatalog {
   layers: string[];
   byLayer: Map<string, ServiceRow[]>;
   byName: Map<string, string>;
+  /**
+   * The read that produced this failed.
+   *
+   * An empty catalog is indistinguishable from a catalog OAP could not be
+   * read for — and the second one, served as though it were the first, told
+   * the operator their layer has no services. After the cache TTL expired
+   * that is what an ordinary refresh during an outage did: it replaced the
+   * service picker with an empty list, and nothing downstream could tell,
+   * because the answer looked successful.
+   */
+  unreachable?: boolean;
 }
 
 export interface ServiceLayerCatalogDeps {
@@ -120,7 +131,7 @@ export class ServiceLayerCatalog {
       layers = Array.isArray(got.layers) ? got.layers : [];
     } catch (err) {
       logger.warn({ err }, 'service-layer-catalog: listLayers failed');
-      return { layers: [], byLayer: new Map(), byName: new Map() };
+      return { layers: [], byLayer: new Map(), byName: new Map(), unreachable: true };
     }
     if (layers.length === 0) {
       return { layers, byLayer: new Map(), byName: new Map() };
@@ -150,7 +161,7 @@ export class ServiceLayerCatalog {
       return { layers, byLayer, byName };
     } catch (err) {
       logger.warn({ err }, 'service-layer-catalog: listServices fan-out failed');
-      return { layers, byLayer: new Map(), byName: new Map() };
+      return { layers, byLayer: new Map(), byName: new Map(), unreachable: true };
     }
   }
 }
diff --git a/apps/bff/src/logic/templates/sync.ts b/apps/bff/src/logic/templates/sync.ts
index 0e9b4a7c..d07f79fc 100644
--- a/apps/bff/src/logic/templates/sync.ts
+++ b/apps/bff/src/logic/templates/sync.ts
@@ -45,6 +45,7 @@ import {
   isOverlayName,
   parseEnvelope,
   serializeEnvelope,
+  TEMPLATE_KINDS,
   type TemplateKind,
 } from './names.js';
 import { iterateBundledOverlays } from './aggregator.js';
@@ -186,6 +187,32 @@ interface CacheEntry {
 let cache: CacheEntry | null = null;
 let inFlight: Promise<SyncStatus> | null = null;
 let lastSuccessfulSyncAt: number | null = null;
+/**
+ * The rows from the last read that SUCCEEDED.
+ *
+ * Kept so a store that becomes unreachable does not blank every dashboard,
+ * overview and map behind it. What renders is then still the operator's own
+ * published configuration — read minutes ago rather than seconds — which is a
+ * different thing entirely from substituting the disk bundle, and the reason
+ * that substitution is forbidden does not apply to it: nothing here is
+ * presented as theirs that is not.
+ *
+ * `unreachable` stays true regardless, so the banner still says the store
+ * cannot be read and how stale this is, and the admin surface stays read-only.
+ * Null until the first success — a Horizon that has never read the store has
+ * nothing legitimate to show and blocks, as before.
+ */
+let lastGoodRows: TemplateRow[] | null = null;
+/**
+ * What the last failed read said, and when.
+ *
+ * Kept because the failure is otherwise only a log line: the Cluster Status
+ * page can say the store is unreachable, but not WHY, and "unreachable" covers
+ * a 404 from a missing module, a 401 from wrong credentials and a timeout from
+ * a slow admin port — three different things to go and fix. Cleared on the next
+ * success, so it always describes the current trouble rather than an old one.
+ */
+let lastError: { message: string; at: number } | null = null;
 
 /** Boot-time template mode (`config.templates.mode`). In `readonly` the
  *  orchestrator never touches the ui_template client — `runOnce` short-circuits
@@ -196,18 +223,87 @@ export function setTemplateReadOnly(on: boolean): void {
   readOnlyMode = on;
   // A mode flip must not serve a stale cross-mode status: drop the cache AND
   // orphan any in-flight probe (it still resolves its awaiters, but won't
-  // backfill the cache with a result computed under the old mode).
+  // backfill the cache with a result computed under the old mode). The
+  // retained rows go too — rows read under the other mode are not this mode's.
   cache = null;
   inFlight = null;
+  lastGoodRows = null;
 }
 export function isTemplateReadOnly(): boolean {
   return readOnlyMode;
 }
 
+/** Force the next caller to re-list OAP. Expires the 30s window ONLY — the
+ *  last good rows survive, because this is what an admin write calls and a
+ *  failed read straight after a push must not blank the page. */
 export function invalidateSyncCache(): void {
   cache = null;
 }
 
+/** Drop everything, including the retained rows. For a mode flip and for tests
+ *  that need the never-read state, where retention would be a false premise. */
+export function resetTemplateSyncState(): void {
+  cache = null;
+  inFlight = null;
+  lastGoodRows = null;
+  lastSuccessfulSyncAt = null;
+  lastError = null;
+}
+
+/**
+ * What the template store is doing, for the Cluster Status page.
+ *
+ * A pure read of module state — it never probes OAP, so the page can render it
+ * as often as it likes without adding load. Counting only `effective: 'remote'`
+ * rows is the point: that is how many templates are actually being SERVED from
+ * OAP, which is the number an operator can check against what they published.
+ */
+export interface TemplateStoreStatus {
+  mode: 'live' | 'readonly';
+  /** True while the last read failed. Rendering may still be fine — see
+   *  `servingRetained`. */
+  unreachable: boolean;
+  /** Epoch ms of the last read that reached OAP; null if none ever has. */
+  lastSuccessfulSyncAt: number | null;
+  /** Rendering from a previous read because the current one failed. The
+   *  distinction the page needs: unreachable AND still serving is a warning,
+   *  unreachable with nothing retained is an outage. */
+  servingRetained: boolean;
+  /** Templates being served from OAP, per kind. Keyed by the kind union so a
+   *  kind added later cannot be silently miscounted into another's bucket. */
+  counts: Record<TemplateKind, number>;
+  /** Per-locale translation overlays being served, across all kinds. */
+  translations: number;
+  /** The last failure, while it is still the current one. */
+  lastError: { message: string; at: number } | null;
+}
+
+export function templateStoreStatus(): TemplateStoreStatus {
+  const rows = lastGoodRows ?? [];
+  const counts = Object.fromEntries(TEMPLATE_KINDS.map((k: TemplateKind) => [k, 0])) as Record<
+    TemplateKind,
+    number
+  >;
+  let translations = 0;
+  for (const row of rows) {
+    if (row.effective !== 'remote') continue;
+    if (row.locale !== undefined) {
+      translations += 1;
+      continue;
+    }
+    counts[row.kind] += 1;
+  }
+  return {
+    mode: readOnlyMode ? 'readonly' : 'live',
+    unreachable: lastError !== null,
+    lastSuccessfulSyncAt,
+    servingRetained: lastError !== null && lastGoodRows !== null,
+    counts,
+    translations,
+    lastError,
+  };
+}
+
 /** On-demand sync. Honors the 30s cache + single-flight. Never writes. */
 export async function getSyncStatus(deps: SyncDeps): Promise<SyncStatus> {
   const now = (deps.now ?? Date.now)();
@@ -350,12 +446,17 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
       { err: errMsg(err), action: opts.write ? 'boot-seed' : 'runtime-sync' },
       'OAP UI-template list failed — rendering bundled, admin read-only',
     );
+    lastError = { message: errMsg(err), at: now };
     return {
       mode: 'live',
       unreachable: true,
       lastSuccessfulSyncAt,
       generatedAt: now,
-      rows: bundledOnlyRows(bundledRows, 'bundled-fallback'),
+      // The last good rows when we have them; the bundled-only view only when
+      // we have never read the store. Those bundled rows are marked
+      // `bundled-fallback`, never `remote`, so every render consumer skips
+      // them and the layer blocks — the live-mode rule is unchanged.
+      rows: lastGoodRows ?? bundledOnlyRows(bundledRows, 'bundled-fallback'),
       conflicts: [],
       unreadable: [],
     };
@@ -395,6 +496,10 @@ async function runOnce(deps: SyncDeps, opts: RunOptions): Promise<SyncStatus> {
   }
 
   const rows = mergeRows(bundledRows, parsedRemote.byName);
+  // What a later failed read falls back to. Stamped only here, on a read that
+  // actually reached OAP.
+  lastGoodRows = rows;
+  lastError = null;
   return {
     mode: 'live',
     unreachable: false,
diff --git a/apps/bff/src/util/window.test.ts b/apps/bff/src/util/window.test.ts
index ae8b18d8..7378b106 100644
--- a/apps/bff/src/util/window.test.ts
+++ b/apps/bff/src/util/window.test.ts
@@ -534,6 +534,24 @@ describe('getServerOffsetMinutes — probing the OAP timezone', () => {
     });
   });
 
+  it('does NOT cache a UTC fallback derived from a probe the caller cancelled', async () => {
+    const url = 'http://oap-cancelled-probe:12800';
+    const ac = new AbortController();
+    // What a real fetch does once the caller's signal fires.
+    const aborting: FetchLike = async () => {
+      ac.abort();
+      const err = new Error('This operation was aborted');
+      err.name = 'AbortError';
+      throw err;
+    };
+    // The probe measured nothing. Swallowing the abort and caching 0 would let
+    // one abandoned request answer every OTHER request for the next minute
+    // with a timezone nobody read.
+    await expect(getServerOffsetMinutes(configFor(url), aborting, ac.signal)).rejects.toThrow();
+    // Cache untouched, so the next live read still gets the truth.
+    expect(await getServerOffsetMinutes(configFor(url), fakeOap(() => '+0800').fetch)).toBe(480);
+  });
+
   it('caches for 60s — a second call inside the window does not re-probe OAP', async () => {
     vi.useFakeTimers();
     vi.setSystemTime(new Date('2026-05-17T00:00:00Z'));
diff --git a/apps/bff/src/util/window.ts b/apps/bff/src/util/window.ts
index 39313cfd..c120280b 100644
--- a/apps/bff/src/util/window.ts
+++ b/apps/bff/src/util/window.ts
@@ -154,6 +154,10 @@ const TIME_INFO_QUERY = /* GraphQL */ `
 export async function getServerOffsetMinutes(
   config: ConfigSource,
   fetchImpl?: FetchLike,
+  /** The caller's cancellation, on a read route. Without it this probe ran to
+   *  completion against OAP for a browser that had already gone — and every
+   *  read route makes it, so an abandoned page cost one of these per request. */
+  signal?: AbortSignal,
 ): Promise<number> {
   const now = Date.now();
   const queryUrl = config.current.oap.queryUrl;
@@ -162,7 +166,7 @@ export async function getServerOffsetMinutes(
   }
   try {
     const env = await graphqlPost<{ time?: { timezone?: string | null } | null }>(
-      buildOapOpts(config.current, fetchImpl),
+      buildOapOpts(config.current, fetchImpl, signal),
       TIME_INFO_QUERY,
     );
     const raw = env.time?.timezone ?? '+0000';
@@ -175,7 +179,11 @@ export async function getServerOffsetMinutes(
       tzCache = { offsetMinutes: offset, fetchedAt: now, queryUrl };
       return offset;
     }
-  } catch {
+  } catch (err) {
+    // A probe WE cancelled says nothing about the server's offset. Caching the
+    // UTC fallback from it would make one abandoned request answer every other
+    // request for the next minute with a timezone nobody measured.
+    if (signal?.aborted) throw err;
     /* fall through to 0 */
   }
   tzCache = { offsetMinutes: 0, fetchedAt: now, queryUrl };
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index 04275be3..20f0b488 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -407,11 +407,29 @@ export interface AdminLayerTemplate {
 export class BffApiError extends Error {
   readonly status: number;
   readonly body: unknown;
-  constructor(status: number, message: string, body: unknown) {
+  /** The request that failed. Carried so a failure can be SHOWN as one —
+   *  a message without its request cannot be acted on. Redact the path
+   *  before displaying it; it may carry query parameters. */
+  readonly method?: string;
+  readonly path?: string;
+  /** WE stopped this request — a capped round, a navigation. Not a failure to
+   *  reach anything, and nothing reports it as one. */
+  readonly cancelled?: boolean;
+  constructor(
+    status: number,
+    message: string,
+    body: unknown,
+    method?: string,
+    path?: string,
+    cancelled?: boolean,
+  ) {
     super(message);
     this.name = 'BffApiError';
     this.status = status;
     this.body = body;
+    this.method = method;
+    this.path = path;
+    this.cancelled = cancelled;
   }
 }
 
@@ -808,6 +826,15 @@ export class BffClient {
     path: string,
     body?: unknown,
     headers?: Record<string, string>,
+    /**
+     * Cancels the request in flight.
+     *
+     * The refresh round caps how long it will hold the scheduler; without a
+     * signal reaching `fetch`, hitting that cap released the TIMER while the
+     * requests carried on and could still land afterwards. Passing one makes
+     * the cap a cancellation rather than a shrug.
+     */
+    signal?: AbortSignal,
   ): Promise<T> {
     // Cold-stage flag is per-request not per-call-site, so the
     // interceptor reads the persisted toggle here rather than every
@@ -815,6 +842,7 @@ export class BffClient {
     const init: RequestInit = {
       method,
       credentials: 'include',
+      ...(signal ? { signal } : {}),
       headers: {
         ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
         ...(readColdStageHeader() ? { [COLD_STAGE_HEADER]: '1' } : {}),
@@ -850,6 +878,14 @@ export class BffClient {
       // fetch() doesn't throw on HTTP-level errors (4xx/5xx) — only on
       // these. They're the "blocked" symptom operators see when the
       // BFF or its upstream is unreachable.
+      // A cancellation is not a failure to reach anything: the round hit its
+      // cap, or the operator navigated away, and WE stopped the request. Logging
+      // it as "the BFF is unreachable" put an outage in the diagnostics for
+      // something the app did deliberately.
+      if (err instanceof Error && err.name === 'AbortError') {
+        pushEvent('api', 'info', `${method} ${path} · cancelled`);
+        throw new BffApiError(0, 'request cancelled', null, method, path, true);
+      }
       const detail = err instanceof Error ? err.message : String(err);
       pushEvent('api', 'err', `${method} ${path} · network ${detail}`);
       // Wrap into a clear, actionable message — the raw "Failed to fetch" /
@@ -859,6 +895,8 @@ export class BffClient {
         0,
         `Cannot reach the server — the BFF is unreachable (${detail}).`,
         null,
+        method,
+        path,
       );
     }
     if (res.status === 401) {
@@ -869,7 +907,7 @@ export class BffClient {
       // explains why everything else in the ticker just vanished.
       this.on401?.();
       pushEvent('api', 'info', `${method} ${path} · 401 (re-auth)`);
-      throw new BffApiError(401, 'unauthenticated', null);
+      throw new BffApiError(401, 'unauthenticated', null, method, path);
     }
     if (!res.ok) {
       // Read the body ONCE as text, then try to parse it as JSON. Calling
@@ -898,7 +936,7 @@ export class BffClient {
         extra = ` · ${parsed}`;
       }
       pushEvent('api', 'err', `${method} ${path} · ${res.status}${extra}`);
-      throw new BffApiError(res.status, `${method} ${path} failed (${res.status})`, parsed);
+      throw new BffApiError(res.status, `${method} ${path} failed (${res.status})`, parsed, method, path);
     }
     if (res.status === 204) return undefined as T;
     const ct = res.headers.get('content-type') ?? '';
@@ -925,6 +963,14 @@ export class BffClient {
     try {
       res = await fetch(url, init);
     } catch (err) {
+      // A cancellation is not a failure to reach anything: the round hit its
+      // cap, or the operator navigated away, and WE stopped the request. Logging
+      // it as "the BFF is unreachable" put an outage in the diagnostics for
+      // something the app did deliberately.
+      if (err instanceof Error && err.name === 'AbortError') {
+        pushEvent('api', 'info', `${method} ${path} · cancelled`);
+        throw new BffApiError(0, 'request cancelled', null, method, path, true);
+      }
       const detail = err instanceof Error ? err.message : String(err);
       pushEvent('api', 'err', `${method} ${path} · network ${detail}`);
       throw new BffApiError(0, `Cannot reach the server — the BFF is unreachable (${detail}).`, null);
@@ -933,7 +979,7 @@ export class BffClient {
       // Hook first, then log — see request(): the hook clears the event log.
       this.on401?.();
       pushEvent('api', 'info', `${method} ${path} · 401 (re-auth)`);
-      throw new BffApiError(401, 'unauthenticated', null);
+      throw new BffApiError(401, 'unauthenticated', null, method, path);
     }
     if (!res.ok) {
       const raw = await res.text().catch(() => '');
@@ -946,7 +992,7 @@ export class BffClient {
         }
       }
       pushEvent('api', 'err', `${method} ${path} · ${res.status}`);
-      throw new BffApiError(res.status, `${method} ${path} failed (${res.status})`, parsed);
+      throw new BffApiError(res.status, `${method} ${path} failed (${res.status})`, parsed, method, path);
     }
     if (res.status === 204) return undefined as T;
     const ct = res.headers.get('content-type') ?? '';
diff --git a/apps/ui/src/api/queryClient.test.ts b/apps/ui/src/api/queryClient.test.ts
new file mode 100644
index 00000000..f37e2ba4
--- /dev/null
+++ b/apps/ui/src/api/queryClient.test.ts
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * What the app retries, and what it must not.
+ *
+ * The two exemptions are easy to lose, because both errors reach this
+ * predicate looking like any other rejection — and a retry that should not
+ * have happened is invisible except as doubled load on a backend that is
+ * already in trouble.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { queryClient } from './queryClient';
+import { BffApiError } from './client';
+
+/** The predicate as the query cache will actually call it. */
+function shouldRetry(err: Error, attempt = 0): boolean {
+  const retry = queryClient.getDefaultOptions().queries?.retry;
+  if (typeof retry !== 'function') throw new Error('retry default is not a predicate');
+  return retry(attempt, err) === true;
+}
+
+describe('the global retry policy', () => {
+  it('retries a genuine transport failure exactly once', () => {
+    const err = new Error('Failed to fetch');
+    expect(shouldRetry(err, 0)).toBe(true);
+    expect(shouldRetry(err, 1)).toBe(false);
+  });
+
+  it('never retries an answer that cannot differ', () => {
+    // A route that replied 200 with `reachable: false` reached us and said it
+    // could not reach OAP. Asking again doubles every request during an outage.
+    const err = new Error('OAP could not be reached');
+    err.name = 'GraphUnavailableError';
+    expect(shouldRetry(err)).toBe(false);
+  });
+
+  it('never retries a request WE cancelled', () => {
+    // The capped round aborts the fan-out precisely so it stops. A retry here
+    // re-issues the request the cancellation existed to stop, which is worse
+    // than not capping at all: the work restarts with the cap already spent.
+    const abort = new Error('This operation was aborted');
+    abort.name = 'AbortError';
+    expect(shouldRetry(abort)).toBe(false);
+
+    const cancelled = new BffApiError(0, 'request cancelled', null, 'POST', '/api/x', true);
+    expect(shouldRetry(cancelled)).toBe(false);
+  });
+
+  it('still retries a real BffApiError that we did not cancel', () => {
+    const failed = new BffApiError(500, 'POST /api/x failed (500)', null, 'POST', '/api/x');
+    expect(shouldRetry(failed, 0)).toBe(true);
+  });
+});
diff --git a/apps/ui/src/api/queryClient.ts b/apps/ui/src/api/queryClient.ts
index 4cd6f76c..7a61488a 100644
--- a/apps/ui/src/api/queryClient.ts
+++ b/apps/ui/src/api/queryClient.ts
@@ -28,7 +28,30 @@ export const queryClient = new QueryClient({
     queries: {
       staleTime: 5_000,
       refetchOnWindowFocus: true,
-      retry: 1,
+      /**
+       * One retry, EXCEPT for an answer that will not change.
+       *
+       * A route that replies 200 with `reachable: false` has answered: it
+       * reached us and said it could not reach OAP. Retrying that doubles every
+       * request during an outage and delays the failed state by a round-trip,
+       * for a result that cannot differ. Genuine transport failures still get
+       * their retry.
+       *
+       * A request WE cancelled is not a failure at all — a capped round, a
+       * navigation, a superseded question. Retrying it re-issues precisely the
+       * request the cancellation existed to stop, so the sixty-second cap would
+       * abort the fan-out and then immediately ask for it again.
+       *
+       * Matched by NAME rather than by class: importing the error here would
+       * close an import cycle back through the graph layer, and the name is
+       * what the class sets.
+       */
+      retry: (count: number, err: Error) => {
+        if (err.name === 'GraphUnavailableError') return false;
+        if (err.name === 'AbortError') return false;
+        if ((err as { cancelled?: boolean }).cancelled === true) return false;
+        return count < 1;
+      },
     },
   },
 });
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index a2167e30..cf019f0d 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -213,6 +213,8 @@ export class LayerApi {
     /** Admin preview: the operator's draft `topology` block (JSON string).
      *  Renders the draft against live OAP instead of the remote template. */
     previewConfig?: string,
+    /** Cancels the request when the refresh round it belongs to is capped. */
+    signal?: AbortSignal,
   ): Promise<TopologyResponse> {
     const qs = new URLSearchParams();
     if (services && services.length > 0) {
@@ -229,6 +231,9 @@ export class LayerApi {
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/topology?${qs.toString()}`,
+      undefined,
+      undefined,
+      signal,
     );
   }
 
@@ -245,6 +250,7 @@ export class LayerApi {
     /** Admin preview: the operator's draft `topology` block (JSON string);
      *  the BFF reads its nested `instanceTopology`. */
     previewConfig?: string,
+    signal?: AbortSignal,
   ): Promise<InstanceTopologyResponse> {
     const qs = new URLSearchParams({ client: clientServiceId, server: serverServiceId });
     if (range) {
@@ -256,6 +262,9 @@ export class LayerApi {
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/instance-topology?${qs.toString()}`,
+      undefined,
+      undefined,
+      signal,
     );
   }
 
@@ -269,6 +278,7 @@ export class LayerApi {
     range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
     /** Admin preview: the operator's draft `deployment` block. */
     previewConfig?: string,
+    signal?: AbortSignal,
   ): Promise<DeploymentResponse> {
     const qs = new URLSearchParams(serviceRefFields(service));
     if (range) {
@@ -280,6 +290,9 @@ export class LayerApi {
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/deployment?${qs.toString()}`,
+      undefined,
+      undefined,
+      signal,
     );
   }
 
@@ -290,6 +303,7 @@ export class LayerApi {
     range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
     /** Admin preview: the operator's draft `endpointDependency` block. */
     previewConfig?: string,
+    signal?: AbortSignal,
   ): Promise<EndpointDependencyResponse> {
     const qs = new URLSearchParams({ ...serviceRefFields(service), endpoint });
     if (range) {
@@ -301,6 +315,9 @@ export class LayerApi {
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/endpoint-dependency?${qs.toString()}`,
+      undefined,
+      undefined,
+      signal,
     );
   }
 
@@ -324,7 +341,11 @@ export class LayerApi {
    *  uses this to validate a URL-pinned `?service=<id>` against the
    *  layer's real catalog — independent of landing's top-N rollup
    *  which can miss low-traffic services. */
-  services(layerKey: string): Promise<{
+  services(
+    layerKey: string,
+    /** Cancels the request when the refresh round it belongs to is capped. */
+    signal?: AbortSignal,
+  ): Promise<{
     reachable: boolean;
     layer: string;
     services: Array<{ id: string; name: string; normal: boolean | null; group: string }>;
@@ -333,6 +354,9 @@ export class LayerApi {
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/services`,
+      undefined,
+      undefined,
+      signal,
     );
   }
 
diff --git a/apps/ui/src/controls/ErrorRecordCard.test.ts b/apps/ui/src/controls/ErrorRecordCard.test.ts
new file mode 100644
index 00000000..dfae32d8
--- /dev/null
+++ b/apps/ui/src/controls/ErrorRecordCard.test.ts
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * What a failure card is allowed to put on the screen.
+ *
+ * Testing the store's redaction is not enough on its own: the card is what an
+ * operator screenshots into a ticket, so the proof has to be about the rendered
+ * DOM. And a card describes text from outside the UI — a server's message, a
+ * URL, a response body — which makes it the last place that should be able to
+ * EXECUTE what it is describing.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { i18n } from '@/i18n';
+import ErrorRecordCard from './ErrorRecordCard.vue';
+import type { UiErrorRecord } from './errorCenter';
+
+const card = (over: Partial<UiErrorRecord> = {}, expanded = false) =>
+  mount(ErrorRecordCard, {
+    props: {
+      record: {
+        id: 'e1',
+        occurredAt: Date.UTC(2026, 0, 1, 12, 0, 0),
+        scope: 'refresh',
+        owner: 'Service map',
+        action: 'reading the service map',
+        summary: 'upstream timed out',
+        ...over,
+      } as UiErrorRecord,
+      expanded,
+    },
+    global: { plugins: [i18n] },
+  });
+
+describe('a card renders text, never markup', () => {
+  it('escapes a summary that looks like a script', () => {
+    const w = card({ summary: '<img src=x onerror="alert(1)">' });
+
+    expect(w.html(), 'the summary reached the DOM as an element').not.toContain('<img');
+    expect(w.text()).toContain('onerror');
+  });
+
+  it('escapes a response body that looks like markup', () => {
+    const w = card({ responseBody: '<script>alert(1)</script>' }, true);
+
+    expect(w.html()).not.toContain('<script>alert(1)</script>');
+    expect(w.text()).toContain('alert(1)');
+  });
+
+  it('escapes a URL that looks like markup', () => {
+    const w = card({ url: '/api/x?q=<b>hi</b>', method: 'GET' }, true);
+
+    expect(w.html()).not.toContain('<b>hi</b>');
+  });
+});
+
+describe('a card says what it knows and no more', () => {
+  it('does not print "0" as a status when there was no response', () => {
+    const w = card({ status: 0 });
+
+    expect(w.text()).not.toMatch(/\b0\b/);
+    expect(w.text().toLowerCase()).toContain('no response');
+  });
+
+  it('shows a real status', () => {
+    expect(card({ status: 503 }).text()).toContain('503');
+  });
+
+  it('offers no details affordance when there are none to show', () => {
+    expect(card().find('.err-more').exists()).toBe(false);
+  });
+
+  it('offers one as soon as there is something behind it', () => {
+    expect(card({ url: '/api/x' }).find('.err-more').exists()).toBe(true);
+  });
+
+  it('keeps the detail closed until it is asked for', () => {
+    const w = card({ responseBody: 'the body' });
+
+    expect(w.text()).not.toContain('the body');
+  });
+
+  it('prefers OUR sentence over the server’s when we have one', () => {
+    // A translatable key wins, because the server has no words for a case it
+    // never reported — an OAP that could not be reached at all, say.
+    const w = card({ summary: 'graph unavailable', summaryKey: 'The request failed.' });
+
+    expect(w.text()).toContain('The request failed.');
+    expect(w.text()).not.toContain('graph unavailable');
+  });
+});
diff --git a/apps/ui/src/controls/ErrorRecordCard.vue b/apps/ui/src/controls/ErrorRecordCard.vue
new file mode 100644
index 00000000..13dbb1c5
--- /dev/null
+++ b/apps/ui/src/controls/ErrorRecordCard.vue
@@ -0,0 +1,133 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts">
+/**
+ * One failure, rendered the same wherever it is read.
+ *
+ * The refresh history and the toasts show the SAME record and must not drift
+ * into two vocabularies for one event — an operator who reads "Topology ·
+ * could not be read" in a toast should find that exact line in the history
+ * afterwards.
+ *
+ * Everything is bound through `{{ }}`. There is no `v-html` here and there
+ * must never be: the summary, the URL and the body all originate outside the
+ * UI, and a failure card is the last place that should be able to execute
+ * what it is describing.
+ */
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+import type { UiErrorRecord } from './errorCenter';
+
+const props = defineProps<{ record: UiErrorRecord; expanded?: boolean }>();
+const emit = defineEmits<{ (e: 'toggle'): void }>();
+const { t } = useI18n({ useScope: 'global' });
+
+const time = computed(() => new Date(props.record.occurredAt).toLocaleTimeString());
+/** A status is worth a line only when there was one — a request that never got
+ *  a reply carries 0, and printing "0" as a status invents an answer. */
+const status = computed(() => (props.record.status ? String(props.record.status) : t('no response')));
+const hasDetail = computed(() =>
+  Boolean(props.record.detail || props.record.responseBody || props.record.url),
+);
+</script>
+
+<template>
+  <div class="err-card">
+    <div class="err-head">
+      <Icon name="alert" :size="12" class="err-icon" />
+      <span class="err-owner">{{ t(record.owner) }}</span>
+      <span class="err-time mono">{{ time }}</span>
+    </div>
+    <div class="err-summary">{{ record.summaryKey ? t(record.summaryKey) : record.summary }}</div>
+    <div class="err-meta mono">
+      <span>{{ t(record.action) }}</span>
+      <span class="sep">·</span>
+      <span>{{ status }}</span>
+      <button
+        v-if="hasDetail"
+        type="button"
+        class="err-more"
+        :aria-expanded="expanded ? 'true' : 'false'"
+        @click.stop="emit('toggle')"
+      >{{ expanded ? t('Hide details') : t('Details') }}</button>
+    </div>
+    <div v-if="expanded && hasDetail" class="err-detail mono">
+      <div v-if="record.url" class="err-line">{{ record.method ?? 'GET' }} {{ record.url }}</div>
+      <div v-if="record.detail" class="err-line">{{ record.detail }}</div>
+      <pre v-if="record.responseBody" class="err-body">{{ record.responseBody }}</pre>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.err-card {
+  padding: 6px 8px;
+  border-radius: 5px;
+  background: var(--sw-bg-2);
+  border: 1px solid var(--sw-line);
+}
+.err-head {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 10.5px;
+}
+.err-icon { color: var(--sw-danger, #e5534b); flex: none; }
+.err-owner { color: var(--sw-fg-1); font-weight: 600; }
+.err-time { margin-left: auto; color: var(--sw-fg-3, var(--sw-fg-2)); font-variant-numeric: tabular-nums; }
+.err-summary {
+  margin-top: 3px;
+  font-size: 11px;
+  color: var(--sw-fg-1);
+  overflow-wrap: anywhere;
+}
+.err-meta {
+  margin-top: 3px;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 10px;
+  color: var(--sw-fg-2);
+}
+.err-meta .sep { opacity: 0.5; }
+.err-more {
+  margin-left: auto;
+  background: none;
+  border: none;
+  padding: 0;
+  color: var(--sw-accent-2, var(--sw-accent));
+  font: inherit;
+  cursor: pointer;
+}
+.err-detail {
+  margin-top: 5px;
+  padding-top: 5px;
+  border-top: 1px solid var(--sw-line);
+  font-size: 10px;
+  color: var(--sw-fg-2);
+}
+.err-line { overflow-wrap: anywhere; }
+.err-body {
+  margin: 4px 0 0;
+  max-height: 180px;
+  overflow: auto;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+  color: var(--sw-fg-2);
+}
+</style>
diff --git a/apps/ui/src/controls/ErrorToastHost.vue b/apps/ui/src/controls/ErrorToastHost.vue
new file mode 100644
index 00000000..b24b354e
--- /dev/null
+++ b/apps/ui/src/controls/ErrorToastHost.vue
@@ -0,0 +1,225 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts">
+/**
+ * Failures the operator caused, shown where they are looking.
+ *
+ * These answer something just DONE — an expansion that found nothing, a save
+ * that was refused — so they appear immediately and leave on their own. Three
+ * details separate a usable toast from one people learn to ignore:
+ *
+ * - **It says how long it has left.** A bar that drains is the difference
+ *   between "it vanished before I read it" and "I could see it going".
+ * - **Attention stops the clock.** Hovering or tabbing in holds it open; a
+ *   message that expires while being read is worse than none.
+ * - **It can be opened.** The summary is one line; the request and the
+ *   response body are a click away, so a toast can be acted on rather than
+ *   just noticed.
+ */
+import { computed, onBeforeUnmount, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+import ErrorRecordCard from './ErrorRecordCard.vue';
+import { useErrorCenterStore } from './errorCenter';
+
+const LIFETIME_MS = 5000;
+/** How often the bar advances. 50ms is imperceptibly smooth and cheap. */
+const TICK_MS = 50;
+
+const { t } = useI18n({ useScope: 'global' });
+const center = useErrorCenterStore();
+
+/** Per-toast milliseconds remaining. Held, not reset, while paused — pausing
+ *  is "stop the clock", not "start again". */
+const remaining = ref<Record<string, number>>({});
+const paused = ref<Record<string, boolean>>({});
+const expanded = ref<string | null>(null);
+/**
+ * Where focus was before a toast took it.
+ *
+ * A toast appears beside whatever the operator was doing, and dismissing it
+ * with Esc should put them back there rather than at the top of the document —
+ * a keyboard user who loses their place has to tab all the way back to it.
+ */
+let focusBeforeToast: HTMLElement | null = null;
+/**
+ * Captured when a toast APPEARS, not when it takes focus.
+ *
+ * Reading `document.activeElement` on `focusin` was too late: the focus update
+ * steps set the active element BEFORE the event fires, so by then it is
+ * already the toast and the "not inside the host" guard never passed. Nothing
+ * was ever captured, and Esc restored nothing.
+ */
+function rememberFocus(): void {
+  const active = typeof document === 'undefined' ? null : document.activeElement;
+  if (active instanceof HTMLElement && !active.closest('.toast-host')) focusBeforeToast = active;
+}
+function restoreFocus(): void {
+  const back = focusBeforeToast;
+  focusBeforeToast = null;
+  if (back && back.isConnected) back.focus();
+}
+
+let timer: ReturnType<typeof setInterval> | null = null;
+function stopTimer(): void {
+  if (timer !== null) {
+    clearInterval(timer);
+    timer = null;
+  }
+}
+function startTimer(): void {
+  if (timer !== null) return;
+  timer = setInterval(() => {
+    for (const toast of center.toasts) {
+      // An expanded toast is being read: it stays until dismissed.
+      if (paused.value[toast.id] || expanded.value === toast.id) continue;
+      const left = (remaining.value[toast.id] ?? LIFETIME_MS) - TICK_MS;
+      if (left <= 0) center.dismissToast(toast.id);
+      else remaining.value = { ...remaining.value, [toast.id]: left };
+    }
+    if (center.toasts.length === 0) stopTimer();
+  }, TICK_MS);
+}
+
+watch(
+  () => center.toasts.map((x) => x.id).join(','),
+  (ids, prev) => {
+    // A toast has just arrived: whatever the operator was on is what Esc
+    // should return them to.
+    if (ids && (!prev || ids.length > prev.length)) rememberFocus();
+    for (const toast of center.toasts) {
+      if (remaining.value[toast.id] === undefined) {
+        remaining.value = { ...remaining.value, [toast.id]: LIFETIME_MS };
+      }
+    }
+    if (center.toasts.length > 0) startTimer();
+    else stopTimer();
+  },
+  { immediate: true },
+);
+onBeforeUnmount(stopTimer);
+
+function pct(id: string): number {
+  return Math.max(0, Math.min(100, ((remaining.value[id] ?? LIFETIME_MS) / LIFETIME_MS) * 100));
+}
+function toggle(id: string): void {
+  expanded.value = expanded.value === id ? null : id;
+}
+function close(id: string): void {
+  if (expanded.value === id) expanded.value = null;
+  center.dismissToast(id);
+  restoreFocus();
+}
+function onKeydown(id: string, ev: KeyboardEvent): void {
+  if (ev.key === 'Escape') {
+    close(id);
+    return;
+  }
+  if (ev.key === 'Enter' || ev.key === ' ') {
+    ev.preventDefault();
+    toggle(id);
+  }
+}
+const hasToasts = computed(() => center.toasts.length > 0);
+</script>
+
+<template>
+  <!-- `alert` rather than `status`: these announce a failure and should
+       interrupt a screen reader rather than wait for a pause. -->
+  <div v-if="hasToasts" class="toast-host" role="alert" aria-live="assertive">
+    <transition-group name="toast">
+      <div
+        v-for="toast in center.toasts"
+        :key="toast.id"
+        class="toast"
+        tabindex="0"
+        @mouseenter="paused[toast.id] = true"
+        @mouseleave="paused[toast.id] = false"
+        @focusin="paused[toast.id] = true"
+        @focusout="paused[toast.id] = false"
+        @click="toggle(toast.id)"
+        @keydown="onKeydown(toast.id, $event)"
+      >
+        <button type="button" class="toast-close" :title="t('Dismiss')" @click.stop="close(toast.id)">
+          <Icon name="close" :size="10" />
+        </button>
+        <ErrorRecordCard :record="toast" :expanded="expanded === toast.id" @toggle="toggle(toast.id)" />
+        <div class="toast-bar"><div class="toast-bar-fill" :style="{ width: `${pct(toast.id)}%` }" /></div>
+      </div>
+    </transition-group>
+  </div>
+</template>
+
+<style scoped>
+.toast-host {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+  z-index: 3000;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  width: 340px;
+  max-width: calc(100vw - 32px);
+}
+.toast {
+  position: relative;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5);
+  cursor: pointer;
+  overflow: hidden;
+}
+.toast:focus-visible {
+  outline: 1px solid var(--sw-accent);
+  outline-offset: 1px;
+}
+.toast-close {
+  position: absolute;
+  top: 4px;
+  right: 4px;
+  z-index: 1;
+  background: none;
+  border: none;
+  padding: 2px;
+  color: var(--sw-fg-2);
+  cursor: pointer;
+  line-height: 0;
+}
+.toast-close:hover {
+  color: var(--sw-fg-1);
+}
+.toast-bar {
+  height: 2px;
+  background: var(--sw-bg-2);
+}
+.toast-bar-fill {
+  height: 100%;
+  background: var(--sw-danger, #e5534b);
+  transition: width 50ms linear;
+}
+.toast-enter-from,
+.toast-leave-to {
+  opacity: 0;
+  transform: translateY(6px);
+}
+.toast-enter-active,
+.toast-leave-active {
+  transition: opacity 0.15s ease, transform 0.15s ease;
+}
+</style>
diff --git a/apps/ui/src/controls/RefreshErrorPanel.vue b/apps/ui/src/controls/RefreshErrorPanel.vue
new file mode 100644
index 00000000..b6241896
--- /dev/null
+++ b/apps/ui/src/controls/RefreshErrorPanel.vue
@@ -0,0 +1,180 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<script setup lang="ts">
+/**
+ * What the timer could not read, kept where the timer lives.
+ *
+ * A background failure has no audience at the moment it happens — nobody asked
+ * for that round — so it waits beside the refresh button behind an unread
+ * count instead of interrupting. The placement is the explanation: these are
+ * the REFRESH's failures, hanging off the control that caused them.
+ *
+ * Five, newest first. During an outage every cycle fails and a longer list
+ * only repeats itself; what an operator needs is "it is still failing, and
+ * here is the last thing it said".
+ */
+import { onBeforeUnmount, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import Icon from '@/components/icons/Icon.vue';
+import ErrorRecordCard from './ErrorRecordCard.vue';
+import { useErrorCenterStore } from './errorCenter';
+import { useEscapeToClose } from '@/components/primitives/useEscapeToClose';
+
+const { t } = useI18n({ useScope: 'global' });
+const center = useErrorCenterStore();
+
+const open = ref(false);
+const expanded = ref<string | null>(null);
+const rootEl = ref<HTMLElement | null>(null);
+
+function toggleOpen(): void {
+  open.value = !open.value;
+  // Opening is reading. The records stay; only the badge clears.
+  if (open.value) center.markRead();
+}
+function onOutside(ev: MouseEvent): void {
+  if (!open.value) return;
+  if (rootEl.value && !rootEl.value.contains(ev.target as Node)) open.value = false;
+}
+useEscapeToClose(
+  () => open.value,
+  () => {
+    open.value = false;
+  },
+);
+if (typeof window !== 'undefined') window.addEventListener('click', onOutside);
+onBeforeUnmount(() => {
+  if (typeof window !== 'undefined') window.removeEventListener('click', onOutside);
+});
+</script>
+
+<template>
+  <div v-if="center.refreshHistory.length > 0" ref="rootEl" class="rerr">
+    <button
+      type="button"
+      class="sw-btn is-icon rerr-btn"
+      :class="{ unread: center.hasUnread }"
+      :title="t('{count} refresh failures', { count: center.refreshHistory.length })"
+      :aria-expanded="open ? 'true' : 'false'"
+      @click.stop="toggleOpen"
+    >
+      <Icon name="alert" :size="12" />
+      <span v-if="center.hasUnread" class="rerr-badge mono">{{ center.unreadCount }}</span>
+    </button>
+    <transition name="rerr-pop">
+      <div v-if="open" class="rerr-panel" @click.stop>
+        <div class="rerr-head">
+          <span>{{ t('Refresh failures') }}</span>
+          <button type="button" class="rerr-clear" @click="center.clearRefreshHistory()">
+            {{ t('Clear') }}
+          </button>
+        </div>
+        <ErrorRecordCard
+          v-for="rec in center.refreshHistory"
+          :key="rec.id"
+          :record="rec"
+          :expanded="expanded === rec.id"
+          @toggle="expanded = expanded === rec.id ? null : rec.id"
+        />
+      </div>
+    </transition>
+  </div>
+</template>
+
+<style scoped>
+.rerr {
+  position: relative;
+  display: inline-flex;
+}
+.rerr-btn {
+  cursor: pointer;
+  position: relative;
+}
+/* Red only while unread. The mark stays after the list is read — a permanent
+   red one would keep claiming attention it no longer needs. */
+.rerr-btn :deep(svg) {
+  color: var(--sw-fg-2);
+}
+.rerr-btn.unread :deep(svg) {
+  color: var(--sw-danger, #e5534b);
+}
+.rerr-badge {
+  position: absolute;
+  top: -3px;
+  right: -3px;
+  min-width: 12px;
+  height: 12px;
+  padding: 0 2px;
+  border-radius: 6px;
+  background: var(--sw-danger, #e5534b);
+  color: #fff;
+  font-size: 8.5px;
+  line-height: 12px;
+  text-align: center;
+  font-variant-numeric: tabular-nums;
+}
+.rerr-panel {
+  position: absolute;
+  top: 100%;
+  right: 0;
+  margin-top: 4px;
+  width: 320px;
+  max-height: 60vh;
+  overflow: auto;
+  padding: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  background: var(--sw-bg-1);
+  border: 1px solid var(--sw-line);
+  border-radius: 6px;
+  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
+  /* Above the connectivity banner (z-index 50), which the panel opens over.
+     At 20 the banner painted THROUGH it — its red strip and text showing
+     across the failure cards, which reads as a rendering fault rather than as
+     two things that happen to overlap. */
+  z-index: 60;
+}
+.rerr-head {
+  display: flex;
+  align-items: center;
+  font-size: 10.5px;
+  color: var(--sw-fg-2);
+  text-transform: uppercase;
+  letter-spacing: 0.04em;
+}
+.rerr-clear {
+  margin-left: auto;
+  background: none;
+  border: none;
+  padding: 0;
+  color: var(--sw-accent-2, var(--sw-accent));
+  font: inherit;
+  text-transform: none;
+  letter-spacing: 0;
+  cursor: pointer;
+}
+.rerr-pop-enter-from,
+.rerr-pop-leave-to {
+  opacity: 0;
+  transform: translateY(-4px);
+}
+.rerr-pop-enter-active,
+.rerr-pop-leave-active {
+  transition: opacity 0.15s ease, transform 0.15s ease;
+}
+</style>
diff --git a/apps/ui/src/controls/autoRefresh.test.ts b/apps/ui/src/controls/autoRefresh.test.ts
new file mode 100644
index 00000000..9ace437e
--- /dev/null
+++ b/apps/ui/src/controls/autoRefresh.test.ts
@@ -0,0 +1,450 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Whose decision is whose.
+ *
+ * The operator owns two things — whether auto-refresh runs at all, and how
+ * often. The app owns a third: pausing while a page or an overlay needs the
+ * background still. Every test here is about those three not overwriting each
+ * other, because each way they can is invisible until an operator notices their
+ * setting did not stick.
+ */
+
+import { describe, expect, it, beforeEach } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+import { useAutoRefreshStore } from './autoRefresh';
+
+beforeEach(() => setActivePinia(createPinia()));
+
+describe('the operator’s switch', () => {
+  it('remembers the cadence across off and on', () => {
+    const a = useAutoRefreshStore();
+    a.setInterval(300);
+
+    a.setEnabled(false);
+    a.setEnabled(true);
+
+    expect(a.intervalSec, 'turning it off forgot the chosen cadence').toBe(300);
+  });
+
+  it('enables when a cadence is picked — choosing 5m is not a request to stay off', () => {
+    const a = useAutoRefreshStore();
+    a.setEnabled(false);
+
+    a.setInterval(300);
+
+    expect(a.enabled).toBe(true);
+  });
+
+  it('stops ticking when off, and the countdown says nothing', () => {
+    const a = useAutoRefreshStore();
+    a.setEnabled(false);
+
+    expect(a.effectiveEnabled).toBe(false);
+    expect(a.secondsUntilNext).toBeNull();
+  });
+});
+
+describe('suspension is the app’s, and never writes the operator’s state', () => {
+  it('does not refresh on release when the operator has it off', () => {
+    const a = useAutoRefreshStore();
+    a.setEnabled(false);
+    const before = a.tickCount;
+
+    // Enter an opt-out page and leave it again.
+    a.suspend('route');
+    a.resume('route');
+
+    expect(a.tickCount, 'leaving an opt-out route refreshed with auto-refresh off').toBe(before);
+    expect(a.enabled, 'suspension wrote the operator’s switch').toBe(false);
+  });
+
+  it('does refresh on release when the operator has it on', () => {
+    const a = useAutoRefreshStore();
+    const before = a.tickCount;
+
+    a.suspend('route');
+    a.resume('route');
+
+    expect(a.tickCount).toBe(before + 1);
+  });
+
+  it('leaves the chosen cadence untouched across a suspend/resume', () => {
+    const a = useAutoRefreshStore();
+    a.setInterval(60);
+
+    a.suspend('route');
+    a.resume('route');
+
+    expect(a.intervalSec).toBe(60);
+  });
+});
+
+describe('two independent holders', () => {
+  // The hierarchy overlay freezes the page so the background stays still while
+  // an operator pans through it. A route change must not release that freeze.
+  it('stays paused while any holder wants it', () => {
+    const a = useAutoRefreshStore();
+    a.suspend('hierarchy-overlay');
+    a.suspend('route');
+
+    a.resume('route');
+
+    expect(a.suspended, 'one holder’s release unfroze another’s claim').toBe(true);
+    expect(a.effectiveEnabled).toBe(false);
+  });
+
+  it('resumes only when the last holder releases', () => {
+    const a = useAutoRefreshStore();
+    a.suspend('hierarchy-overlay');
+    a.suspend('route');
+    a.resume('route');
+
+    a.resume('hierarchy-overlay');
+
+    expect(a.suspended).toBe(false);
+    expect(a.effectiveEnabled).toBe(true);
+  });
+
+  it('ignores a release from something that never held it', () => {
+    const a = useAutoRefreshStore();
+    a.suspend('hierarchy-overlay');
+    const before = a.tickCount;
+
+    a.resume('route');
+
+    expect(a.suspended).toBe(true);
+    expect(a.tickCount).toBe(before);
+  });
+
+  it('is idempotent per holder — a second claim is not a second lock', () => {
+    const a = useAutoRefreshStore();
+    a.suspend('route');
+    a.suspend('route');
+
+    a.resume('route');
+
+    expect(a.suspended).toBe(false);
+  });
+});
+
+describe('manual refresh', () => {
+  // The one thing that must keep working while auto-refresh is off: the
+  // operator asked for data, explicitly.
+  it('ticks even when auto-refresh is off', () => {
+    const a = useAutoRefreshStore();
+    a.setEnabled(false);
+    const before = a.tickCount;
+
+    a.refreshNow();
+
+    expect(a.tickCount).toBe(before + 1);
+  });
+});
+
+describe('a round counts from completion', () => {
+  it('waits for its subscribers before arming the next interval', async () => {
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    const slow = new Promise<void>((r) => { release = r; });
+    let calls = 0;
+    a.joinRound(() => { calls += 1; return slow; });
+
+    const round = a.refreshNow();
+    await Promise.resolve();
+    expect(calls).toBe(1);
+
+    // A trigger arriving mid-round does not start a second one ON TOP of it.
+    // Two rounds in flight would land two windows' answers interleaved, which
+    // is the state this whole design exists to prevent.
+    a.refreshNow();
+    await Promise.resolve();
+    expect(calls, 'a trigger during a round started a second one').toBe(1);
+
+    release();
+    await round;
+  });
+
+  it('collapses however many mid-round triggers into ONE trailing round', async () => {
+    // Not dropped, and not queued. Dropped was wrong for the triggers that mean
+    // something CHANGED — returning to the tab, leaving a paused page — because
+    // the round in flight was asking about the state before that happened.
+    // Queueing them all would be worse: on a slow backend the backlog would
+    // outlive the windows it was asking about.
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    const slow = new Promise<void>((r) => { release = r; });
+    let calls = 0;
+    a.joinRound(() => { calls += 1; return calls === 1 ? slow : undefined; });
+
+    const round = a.refreshNow();
+    await Promise.resolve();
+    a.refreshNow();
+    a.refreshNow();
+    a.refreshNow();
+
+    release();
+    await round;
+    await Promise.resolve();
+    await Promise.resolve();
+
+    expect(calls, 'three mid-round triggers did not collapse into one').toBe(2);
+  });
+
+  it('drops a subscriber that unregisters', async () => {
+    const a = useAutoRefreshStore();
+    let calls = 0;
+    const off = a.joinRound(() => { calls += 1; });
+    await a.refreshNow();
+    expect(calls).toBe(1);
+
+    off();
+    await a.refreshNow();
+
+    expect(calls, 'an unregistered subscriber was still called').toBe(1);
+  });
+
+  it('survives a subscriber that throws, and still refreshes the others', async () => {
+    const a = useAutoRefreshStore();
+    let good = 0;
+    a.joinRound(() => { throw new Error('boom'); });
+    a.joinRound(() => { good += 1; });
+
+    await a.refreshNow();
+
+    expect(good).toBe(1);
+  });
+
+  it('bumps the tick before the work, so the window is re-anchored first', async () => {
+    const a = useAutoRefreshStore();
+    let tickAtCallTime = -1;
+    a.joinRound(() => { tickAtCallTime = a.tickCount; });
+    const before = a.tickCount;
+
+    await a.refreshNow();
+
+    expect(tickAtCallTime).toBe(before + 1);
+  });
+});
+
+describe('the timer is stopped while a round is out', () => {
+  it('arms nothing new until the round settles', async () => {
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    const slow = new Promise<void>((r) => { release = r; });
+    a.joinRound(() => slow);
+
+    const round = a.refreshNow();
+    await Promise.resolve();
+
+    // NOTHING to count while a round is out: the next round starts when this
+    // one ends, and that instant does not exist yet. Reporting a number would
+    // state a deadline nobody can hold the page to — the chip says
+    // "Refreshing" instead.
+    expect(a.secondsUntilNext).toBeNull();
+
+    release();
+    await round;
+    // Settled: counting again, from now.
+    expect(a.secondsUntilNext).toBeLessThanOrEqual(a.intervalSec);
+    expect(a.secondsUntilNext).toBeGreaterThan(a.intervalSec - 3);
+  });
+
+  it('reports nothing to count when the operator turned it off', () => {
+    const a = useAutoRefreshStore();
+    a.setEnabled(false);
+    expect(a.secondsUntilNext).toBeNull();
+  });
+
+  it('reports nothing to count while suspended, even mid-round', async () => {
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    a.joinRound(() => new Promise<void>((r) => { release = r; }));
+    const round = a.refreshNow();
+    await Promise.resolve();
+
+    a.suspend('route');
+
+    expect(a.secondsUntilNext).toBeNull();
+    release();
+    await round;
+  });
+});
+
+describe('a round that has started always finishes', () => {
+  it('completes even when the page suspends mid-flight, and arms no successor', async () => {
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    let finished = false;
+    a.joinRound(() => new Promise<void>((r) => { release = () => { finished = true; r(); }; }));
+
+    const round = a.refreshNow();
+    await Promise.resolve();
+
+    // Entering an opt-out page while the readings are out.
+    a.suspend('route');
+    release();
+    await round;
+
+    expect(finished, 'the in-flight round was abandoned when the page suspended').toBe(true);
+    expect(a.effectiveEnabled, 'a successor round was armed while suspended').toBe(false);
+  });
+
+  it('completes even when the operator switches auto-refresh off mid-flight', async () => {
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    let finished = false;
+    a.joinRound(() => new Promise<void>((r) => { release = () => { finished = true; r(); }; }));
+
+    const round = a.refreshNow();
+    await Promise.resolve();
+
+    a.setEnabled(false);
+    release();
+    await round;
+
+    expect(finished).toBe(true);
+    expect(a.secondsUntilNext, 'still counting toward a round that will not come').toBeNull();
+  });
+});
+
+describe('a slow backend cannot stack rounds', () => {
+  // Acceptance: a 5-second cadence meeting an 8-second round. At most one round
+  // is ever out, and the next full interval starts only once it lands — the
+  // failure this rules out is a page whose requests overlap until the backend
+  // gives way entirely.
+  it('never has two rounds out, and counts the interval from the landing', async () => {
+    const a = useAutoRefreshStore();
+    a.setInterval(5);
+    let out = 0;
+    let peak = 0;
+    let rounds = 0;
+    let release!: () => void;
+    a.joinRound(() => {
+      rounds += 1;
+      out += 1;
+      peak = Math.max(peak, out);
+      // Only the FIRST round is slow. The trailing one the mid-round triggers
+      // queue must be allowed to finish, or the page never goes idle and the
+      // countdown assertion below has nothing to measure.
+      if (rounds > 1) { out -= 1; return undefined; }
+      return new Promise<void>((r) => {
+        release = () => { out -= 1; r(); };
+      });
+    });
+
+    const round = a.refreshNow();
+    await Promise.resolve();
+    // Everything that could start a second one while the first is out.
+    a.refreshNow();
+    a.refreshNow();
+    await Promise.resolve();
+
+    expect(peak, 'two rounds were in flight at once').toBe(1);
+    // And nothing to count toward while it is out.
+    expect(a.secondsUntilNext).toBeNull();
+
+    release();
+    await round;
+    await a.whenIdle();
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(peak, 'the trailing round overlapped its predecessor').toBe(1);
+    expect(a.secondsUntilNext, 'the interval did not start from the landing').toBeLessThanOrEqual(5);
+    expect(a.secondsUntilNext).toBeGreaterThan(2);
+  });
+
+  it('hands a caller arriving mid-round the round that is actually out', async () => {
+    const a = useAutoRefreshStore();
+    let release!: () => void;
+    let settled = false;
+    a.joinRound(() => new Promise<void>((r) => { release = () => { settled = true; r(); }; }));
+
+    const first = a.refreshNow();
+    await Promise.resolve();
+    // Awaiting this used to mean awaiting nothing: it resolved immediately
+    // while the round it was asking about was still out.
+    const joined = a.refreshNow();
+    let joinedResolved = false;
+    void joined.then(() => { joinedResolved = true; });
+    await Promise.resolve();
+
+    expect(joinedResolved, 'a mid-round call resolved before the round did').toBe(false);
+
+    release();
+    await first;
+    await joined;
+    expect(settled).toBe(true);
+  });
+});
+
+describe('the operator’s switch respects where they are', () => {
+  // Switching auto-refresh on while a page is paused or the tab is hidden used
+  // to fire a round immediately — the opposite of what both states mean, and a
+  // round's worth of queries for a page nobody is looking at.
+  it('saves the preference without refreshing while suspended', () => {
+    const a = useAutoRefreshStore();
+    a.setEnabled(false);
+    a.suspend('route');
+    const before = a.tickCount;
+
+    a.setEnabled(true);
+
+    expect(a.enabled, 'the preference was not saved').toBe(true);
+    expect(a.tickCount, 'a paused page was refreshed anyway').toBe(before);
+  });
+
+  it('reports itself as not running when the tab is hidden', () => {
+    const a = useAutoRefreshStore();
+    expect(a.effectiveEnabled).toBe(true);
+
+    Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+    document.dispatchEvent(new Event('visibilitychange'));
+
+    expect(a.effectiveEnabled, 'a backgrounded tab still claimed to be refreshing').toBe(false);
+    Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+    document.dispatchEvent(new Event('visibilitychange'));
+  });
+});
+
+describe('the round says what it is', () => {
+  it('gives every participant one context — id, trigger, window and stage', async () => {
+    const a = useAutoRefreshStore();
+    const seen: Array<NonNullable<typeof a.currentRound>> = [];
+    a.joinRound(() => { if (a.currentRound) seen.push({ ...a.currentRound }); });
+    a.joinRound(() => { if (a.currentRound) seen.push({ ...a.currentRound }); });
+
+    await a.refreshNow();
+
+    expect(seen).toHaveLength(2);
+    expect(seen[0]?.trigger).toBe('manual');
+    expect(seen[0]?.roundId).toBe(seen[1]?.roundId);
+    // The window is decided once, at the top, so two screens in one round
+    // cannot describe different windows.
+    expect(seen[0]?.startMs).toBe(seen[1]?.startMs);
+    expect(seen[0]?.endMs).toBe(seen[1]?.endMs);
+    expect(seen[0]?.step).toBe(seen[1]?.step);
+    expect(seen[0]?.coldStage).toBe(seen[1]?.coldStage);
+  });
+
+  it('is cleared once the round is over, so nothing can attribute to it late', async () => {
+    const a = useAutoRefreshStore();
+    await a.refreshNow();
+    expect(a.currentRound).toBeNull();
+  });
+});
diff --git a/apps/ui/src/controls/autoRefresh.ts b/apps/ui/src/controls/autoRefresh.ts
index bab514b0..79a1ee37 100644
--- a/apps/ui/src/controls/autoRefresh.ts
+++ b/apps/ui/src/controls/autoRefresh.ts
@@ -18,33 +18,96 @@
 /**
  * Global auto-refresh ticker.
  *
- * One pinia store schedules a tick every `intervalSec` seconds.
- * Components subscribe via `watch(autoRefresh.tickCount, refetch)`.
+ * One store, one timer, for the whole page. Screens REGISTER with `joinRound`
+ * and the store calls them: it re-anchors the window once, runs every
+ * subscriber, waits for all of them, and only then counts toward the next
+ * round. Watching `tickCount` to refetch was the older arrangement and is not
+ * one any more — a screen that both registers and watches fetches twice per
+ * round.
  *
  * Pages that own their own time range (e.g. /layer/.../trace) call
  * `suspend()` when they mount and `resume()` when they leave. The
  * topbar wires this up via a `route` watcher with an opt-out regex
  * list, so individual pages don't need to know about the ticker.
  *
+ * THREE pieces of state, deliberately separate:
+ *   - `intervalSec` — the cadence the operator chose. ALWAYS a number; it
+ *     survives being turned off, so switching back on returns to it.
+ *   - `enabled`     — the operator's global on/off.
+ *   - `suspendedBy` — a SET of reasons held by the app: an opt-out route, the
+ *     hierarchy overlay. Ticking pauses while any holder wants it and resumes
+ *     only when the last one releases. A boolean could not express two
+ *     independent holders, and whichever released first unfroze both.
+ *
+ * The rule that makes the split worth having: **suspension never writes
+ * operator state.** Entering an opt-out page adds a reason; leaving removes it
+ * and touches nothing else, so `enabled` and `intervalSec` are preserved by
+ * never having been altered.
+ *
  * UI hooks:
- *   - `intervalSec`        — current interval (null = off).
- *   - `effectiveEnabled`   — true when ticker is running (interval
- *                            set AND not suspended).
+ *   - `intervalSec`        — the chosen cadence (never null).
+ *   - `enabled`            — the operator's switch.
+ *   - `effectiveEnabled`   — true when ticker is running (enabled AND
+ *                            nothing suspending).
  *   - `secondsUntilNext`   — live countdown (re-evaluated by the
  *                            visible-countdown ref ticking once a
  *                            second).
- *   - `tickCount`          — increments on every fire; subscribers
- *                            watch this to refetch.
+ *   - `tickCount`          — increments once per round, BEFORE the work, so
+ *                            the time store re-anchors the rolling window
+ *                            first. Not a subscription mechanism; see above.
+ *   - `roundRunning`       — a round is out. The countdown reads it, and so
+ *                            does anything that must not re-anchor mid-round.
+ *   - `currentRound`       — what that round IS (id, trigger, start), so a
+ *                            failure can be attributed to it.
  */
 
 import { defineStore } from 'pinia';
-import { computed, onScopeDispose, ref } from 'vue';
+import { computed, onScopeDispose, ref, shallowRef } from 'vue';
+import { queryClient } from '@/api/queryClient';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import { readColdStageHeader } from '@/controls/coldStage';
+
+/**
+ * What one round IS, published while it runs.
+ *
+ * Everything a subscriber needs to describe the round it is part of, decided
+ * ONCE at the start: the same id and the same trigger for every screen in it.
+ * Without it each subscriber answered "which round am I in?" for itself, and
+ * two screens refreshed by the same tick could disagree.
+ */
+export interface RefreshRoundContext {
+  roundId: number;
+  trigger: RefreshTrigger;
+  /** The window every participant in this round asks about. Decided once, at
+   *  the top, so two screens in one round cannot describe different windows. */
+  startMs: number;
+  endMs: number;
+  step: string;
+  /** The stage the round reads from. Part of the context rather than a header
+   *  read per request, so a flip mid-round cannot split it. */
+  coldStage: boolean;
+  startedAt: number;
+}
+
+/** Who set a round off. Kept apart from the round itself because the answer
+ *  changes what a failure MEANS: a timer round nobody asked for belongs in the
+ *  history, an operator's click deserves an answer on screen. */
+export type RefreshTrigger =
+  | 'auto'
+  | 'manual'
+  | 'time-change'
+  | 'cold-stage'
+  | 'visibility'
+  | 'resume';
 
 export const useAutoRefreshStore = defineStore('auto-refresh', () => {
-  const intervalSec = ref<number | null>(30);
+  const intervalSec = ref<number>(30);
+  const enabled = ref(true);
   const tickCount = ref(0);
   const lastTickAt = ref(Date.now());
-  const suspended = ref(false);
+  /** Reason keys, not a flag — see the header. */
+  const suspendedBy = ref<Set<string>>(new Set());
+  const suspended = computed(() => suspendedBy.value.size > 0);
   const nowMs = ref(Date.now());
   // Track the document's visibility separately from `suspended`. Both
   // gate the main timer; we keep them distinct so resuming via route
@@ -54,23 +117,278 @@ export const useAutoRefreshStore = defineStore('auto-refresh', () => {
     typeof document === 'undefined' || document.visibilityState !== 'hidden',
   );
 
-  let timerId: ReturnType<typeof setInterval> | null = null;
+  /**
+   * Subscribers that make up a ROUND. The ticker awaits them, so the next
+   * interval is counted from when the work finished rather than from when it
+   * started — on a slow backend a fixed rate simply overlaps rounds, and the
+   * displayed countdown describes wall-clock rather than anything an operator
+   * can act on.
+   */
+  const subscribers = new Set<(signal: AbortSignal) => Promise<unknown> | unknown>();
+  function joinRound(fn: (signal: AbortSignal) => Promise<unknown> | unknown): () => void {
+    subscribers.add(fn);
+    return () => { subscribers.delete(fn); };
+  }
+  /**
+   * How long a round may hold the scheduler before it is CANCELLED.
+   *
+   * The cap aborts the round's signal rather than walking away from it. The
+   * difference matters: releasing the scheduler while requests are still out
+   * lets the next round start on top of them, so two windows' answers land
+   * interleaved — which is the chaos this whole design exists to prevent.
+   */
+  const ROUND_CAP_MS = 60_000;
+  /**
+   * How long after the abort the round still waits before releasing anyway.
+   *
+   * A subscriber that cannot honour a signal would otherwise hold the
+   * scheduler for ever, and one wedged screen must not stop every other one
+   * refreshing. Releasing here is a decision to move on, not a claim that the
+   * request stopped.
+   */
+  const ABORT_GRACE_MS = 5_000;
+  /** True while a round is out. Reactive because the countdown reads it: while
+   *  the work is in flight there is no next-tick instant to count toward. */
+  const roundRunning = ref(false);
+  /** The round now out, or null. Subscribers read it to attribute a failure to
+   *  the round that caused it. */
+  const currentRound = shallowRef<RefreshRoundContext | null>(null);
+  let roundSeq = 0;
+  /**
+   * A trigger that arrived while a round was out.
+   *
+   * COALESCED, not queued: however many arrive, exactly one more round runs
+   * when this one settles. Dropping them outright was wrong for the triggers
+   * that mean something changed — coming back to the tab, leaving a paused
+   * page — because the round in flight was asking about the state BEFORE that
+   * happened. Queueing them all would be worse: a slow backend would build a
+   * backlog of rounds each asking about a window already gone.
+   */
+  let trailing: RefreshTrigger | null = null;
+  /** The round now out, as a promise, so a caller arriving mid-round can await
+   *  the real thing. */
+  let activeRound: Promise<void> | null = null;
+  /**
+   * Waiters for "the page has stopped refreshing".
+   *
+   * Distinct from awaiting a round, and the difference is exactly the
+   * coalescing case: a trigger arriving mid-round resolves its caller
+   * immediately, because the round it asked for has not started yet. Anything
+   * that must act AFTER the page has settled — re-reading what a round did not
+   * cover, say — has to wait for the trailing round too.
+   */
+  /**
+   * Told when a round gave up at its cap.
+   *
+   * A callback rather than a direct call into the error centre: this store is
+   * imported by the time store, and reaching the other way would close a cycle
+   * through Pinia at module-evaluation time. The shell wires it once.
+   */
+  let onCapped: ((round: RefreshRoundContext) => void) | null = null;
+  function onRoundCapped(fn: (round: RefreshRoundContext) => void): void {
+    onCapped = fn;
+  }
+  let idleWaiters: Array<() => void> = [];
+  function notifyIdle(): void {
+    const waiting = idleWaiters;
+    idleWaiters = [];
+    for (const resolve of waiting) resolve();
+  }
+  function whenIdle(): Promise<void> {
+    if (!roundRunning.value && trailing === null) return Promise.resolve();
+    return new Promise<void>((resolve) => idleWaiters.push(resolve));
+  }
+  /** When the last round SETTLED. Kept for `tickCount` consumers and tests. */
+  const lastRoundEndAt = ref(Date.now());
+  /**
+   * When the armed timeout will actually fire, or null when nothing is armed.
+   *
+   * The countdown reads THIS rather than deriving a deadline, so the number on
+   * screen is the timer's own.
+   */
+  const nextRunAt = ref<number | null>(null);
+  /**
+   * Anything at all is loading.
+   *
+   * Not just a round: a page's first read, a picker's cascade, a widget batch.
+   * The operator's rule is that loading time is not counted against the
+   * interval — a countdown that keeps draining while the page is visibly busy
+   * describes a schedule the page is not keeping. So the timer holds while this
+   * is true and starts a FULL interval once it clears.
+   */
+  const busy = ref(false);
+
+  /**
+   * Track in-flight query traffic, so the timer can hold while the page loads.
+   *
+   * Read from the query cache rather than `useIsFetching`, which needs a
+   * component context this store does not have. The subscription is torn down
+   * with the store.
+   */
+  const stopWatchingTraffic = queryClient.getQueryCache().subscribe(() => {
+    // Only PAGE traffic counts. The shell's own pollers (OAP info, layers,
+    // admin features, the alarm badge) run on independent clocks by design, and
+    // counting them made the coordinated timer restart every time one of them
+    // ticked — a 30-second poller against a 30-second cadence postponed every
+    // round indefinitely, so auto-refresh silently never happened.
+    const now = queryClient
+      .getQueryCache()
+      .getAll()
+      .some((q) => q.state.fetchStatus === 'fetching' && q.meta?.independentPoll !== true);
+    if (now === busy.value) return;
+    busy.value = now;
+    // Settling starts a FULL interval — "全部轮次完成后才开始完整的新周期". Going
+    // busy disarms, so nothing fires while the page is still filling in.
+    if (!now) startMainTimer();
+    else clearMainTimer();
+  });
+
+  let timerId: ReturnType<typeof setTimeout> | null = null;
   let countdownId: ReturnType<typeof setInterval> | null = null;
 
   function clearMainTimer(): void {
     if (timerId) {
-      clearInterval(timerId);
+      clearTimeout(timerId);
       timerId = null;
     }
   }
+  /** Arm the NEXT round, counted from now — i.e. from the moment the previous
+   *  one settled, since this is called when it does. */
   function startMainTimer(): void {
     clearMainTimer();
-    if (intervalSec.value === null || suspended.value || !tabVisible.value) return;
-    const ms = intervalSec.value * 1000;
-    timerId = setInterval(() => {
-      lastTickAt.value = Date.now();
-      tickCount.value++;
-    }, ms);
+    nextRunAt.value = null;
+    if (!enabled.value || suspended.value || !tabVisible.value) return;
+    // While anything is loading there is no deadline to hold the page to. The
+    // timer is re-armed, for a FULL interval, when the traffic settles.
+    if (busy.value) return;
+    const delay = intervalSec.value * 1000;
+    // Stamped HERE, where the timeout is actually armed, so the countdown and
+    // the timer cannot disagree. Deriving it from the last round's end instead
+    // was wrong at three of this function's four call sites — they re-arm the
+    // timeout without ending a round, so the countdown kept running against an
+    // older stamp and the next round fired while it still read several seconds.
+    nextRunAt.value = Date.now() + delay;
+    timerId = setTimeout(() => { void runRound('auto'); }, delay);
+  }
+
+  /**
+   * One refresh round: tell every subscriber to refetch, wait for them, then
+   * arm the next interval.
+   *
+   * Coalesced rather than queued: a trigger arriving while a round is out is
+   * dropped, because it would ask for exactly what is already being fetched.
+   * The four triggers — timer, manual refresh, resume, visibility-return — all
+   * come through here, so they cannot interleave differently.
+   */
+  function runRound(trigger: RefreshTrigger = 'auto'): Promise<void> {
+    if (roundRunning.value) {
+      // The TIMER never queues. Its own timeout is cleared for the duration of
+      // a round, so one arriving here is a stale fire from before the round
+      // began — asking for a repeat of what is already being fetched.
+      if (trigger !== 'auto') trailing = trigger;
+      // The caller gets the round that is actually out, not an empty promise.
+      // Returning immediately made "await a refresh" mean "await nothing" for
+      // every caller that arrived a moment late.
+      return activeRound ?? Promise.resolve();
+    }
+    const round = runRoundInner(trigger);
+    activeRound = round;
+    return round;
+  }
+
+  async function runRoundInner(trigger: RefreshTrigger): Promise<void> {
+    roundRunning.value = true;
+    roundSeq += 1;
+    // The timer is STOPPED for the duration, explicitly. A timeout armed
+    // before this round began is still pending and would fire mid-round —
+    // coalesced by the guard above, but only by accident. Clearing it makes
+    // "one round at a time" a property of the scheduler rather than a
+    // consequence of a check somewhere else.
+    clearMainTimer();
+    lastTickAt.value = Date.now();
+    // Bumped BEFORE the work: the time store re-anchors the rolling window on
+    // this, synchronously, so the requests below ask about the window as of
+    // now rather than the one the previous round used. Once, for the whole
+    // round — every screen in it therefore asks about the SAME window, which
+    // per-subscriber re-anchoring could not promise.
+    tickCount.value++;
+    // Read AFTER the re-anchor, so the context describes the window this round
+    // will actually ask about rather than the previous one's.
+    const timeRange = useTimeRangeStore();
+    currentRound.value = {
+      roundId: roundSeq,
+      trigger,
+      startMs: timeRange.range.startMs,
+      endMs: timeRange.range.endMs,
+      step: timeRange.step,
+      coldStage: readColdStageHeader(),
+      startedAt: Date.now(),
+    };
+    const controller = new AbortController();
+    // Whether THIS round hit its cap. Reported after the round settles, because
+    // a cap that leaves no trace is indistinguishable from a round that simply
+    // took a while — and the operator is looking at a page whose data may be
+    // from before it.
+    let capped_ = false;
+    try {
+      const work = [...subscribers].map((fn) => {
+        try {
+          return Promise.resolve(fn(controller.signal));
+        } catch {
+          // A subscriber that throws synchronously must not take the round
+          // down with it; the others still deserve their refresh.
+          return Promise.resolve();
+        }
+      });
+      const settled = Promise.allSettled(work);
+      let capId: ReturnType<typeof setTimeout> | undefined;
+      const capped = new Promise<void>((resolve) => {
+        capId = setTimeout(() => {
+          capped_ = true;
+          controller.abort();
+          // Cancelled, then given a moment to actually stop. Resolving the
+          // instant we abort would be the same premature release the cap was
+          // supposed to replace.
+          setTimeout(resolve, ABORT_GRACE_MS);
+        }, ROUND_CAP_MS);
+      });
+      await Promise.race([settled, capped]);
+      if (capId) clearTimeout(capId);
+    } finally {
+      roundRunning.value = false;
+      const finished = currentRound.value;
+      currentRound.value = null;
+      activeRound = null;
+      if (capped_ && finished) onCapped?.(finished);
+      lastRoundEndAt.value = Date.now();
+      const pending = trailing;
+      trailing = null;
+      // A trailing round runs only if it is still wanted. The operator may have
+      // switched off, or the page suspended, while the round it was queued
+      // behind was out — a manual click is the exception, because asking for
+      // data explicitly works whether or not the timer does.
+      // An operator ACTION is always still wanted, whatever the timer is doing:
+      // a manual click, a time-range change, a cold-stage flip. Only `manual`
+      // used to qualify, so a range picked while a round was out — which
+      // suspends auto-refresh when it is a custom range — had its corrective
+      // round silently dropped, leaving the page on the previous window.
+      // Not into a hidden tab, though: nobody is looking, and the visibility
+      // handler has already stood the schedule down.
+      const operatorAsked =
+        pending === 'manual' || pending === 'time-change' || pending === 'cold-stage';
+      const stillWanted =
+        tabVisible.value && (operatorAsked || (enabled.value && !suspended.value));
+      if (pending !== null && stillWanted) {
+        // Not idle yet — the successor notifies when IT settles.
+        void runRound(pending);
+      } else {
+        // Declines to arm if the operator switched off, or something suspended,
+        // while this round was out — the round finishes, the next one does not
+        // begin.
+        startMainTimer();
+        notifyIdle();
+      }
+    }
   }
   function ensureCountdown(): void {
     if (countdownId) return;
@@ -93,7 +411,7 @@ export const useAutoRefreshStore = defineStore('auto-refresh', () => {
     if (visibleNow === tabVisible.value) return;
     tabVisible.value = visibleNow;
     if (visibleNow) {
-      if (!suspended.value && intervalSec.value !== null) refreshNow();
+      if (!suspended.value && enabled.value) void runRound('visibility');
     } else {
       clearMainTimer();
     }
@@ -102,50 +420,118 @@ export const useAutoRefreshStore = defineStore('auto-refresh', () => {
     document.addEventListener('visibilitychange', onVisibilityChange);
   }
 
-  /** Operator picks a new interval (or `null` to disable). */
-  function setInterval_(sec: number | null): void {
+  /**
+   * Operator picks a cadence. Picking one also ENABLES: choosing "every
+   * minute" is not a request to stay off.
+   */
+  function setInterval_(sec: number): void {
     intervalSec.value = sec;
+    enabled.value = true;
     startMainTimer();
   }
 
-  /** Fire one tick now (manual refresh). Resets the timer so the
-   *  next auto-fire is `intervalSec` from now, not from the previous
-   *  tick. */
-  function refreshNow(): void {
-    lastTickAt.value = Date.now();
-    tickCount.value++;
-    startMainTimer();
+  /**
+   * The operator's global switch. `intervalSec` is untouched, so off → on
+   * returns to the cadence last chosen rather than to a default.
+   */
+  function setEnabled(on: boolean): void {
+    if (enabled.value === on) return;
+    enabled.value = on;
+    if (on) void enableRefresh();
+    else clearMainTimer();
+  }
+  function toggleEnabled(): void {
+    setEnabled(!enabled.value);
+  }
+
+  /**
+   * Run a round now (manual refresh). The next auto-fire is `intervalSec` from
+   * when this one SETTLES, not from when it started.
+   *
+   * Returns the round so a caller — or a test — can wait for it. Callers that
+   * only want it started may ignore the promise.
+   */
+  function refreshNow(trigger: RefreshTrigger = 'manual'): Promise<void> {
+    return runRound(trigger);
   }
 
-  /** Suspend ticking (e.g. when entering an opt-out route). */
-  function suspend(): void {
-    if (suspended.value) return;
-    suspended.value = true;
+  /**
+   * Switching auto-refresh ON.
+   *
+   * Saves the preference, and refreshes only if a round could actually run. On
+   * a hidden tab or a paused page it used to fire one immediately — which is
+   * the opposite of what those states mean, and put a round's worth of queries
+   * on a backend for a page nobody was looking at.
+   */
+  function enableRefresh(): void {
+    if (suspended.value || !tabVisible.value) {
+      startMainTimer();
+      return;
+    }
+    void runRound('resume');
+  }
+
+  /**
+   * Hold ticking for a NAMED reason (an opt-out route, the hierarchy overlay).
+   * Idempotent per reason, and holders do not interfere: two can hold at once.
+   */
+  function suspend(reason = 'route'): void {
+    if (suspendedBy.value.has(reason)) return;
+    const next = new Set(suspendedBy.value);
+    next.add(reason);
+    suspendedBy.value = next;
+    // Stops the NEXT round, never the one in flight. A round CAN be cancelled
+    // now — its cap does exactly that — but cancelling one halfway on a pause
+    // would leave the page holding whatever fraction had landed, beside values
+    // from the round before, with nothing on screen to say so. A round that has
+    // started always finishes; `startMainTimer` in its `finally` then declines
+    // to arm another while this holds.
     clearMainTimer();
   }
 
-  /** Resume ticking AND fire one immediate tick — operators leaving
-   *  an opt-out route expect the visible data to refresh right
-   *  away, then continue ticking. */
-  function resume(): void {
-    if (!suspended.value) return;
-    suspended.value = false;
-    refreshNow();
+  /**
+   * Release one reason. Ticking resumes only when the LAST holder lets go —
+   * releasing another's claim is what let the hierarchy overlay's freeze be
+   * broken by an unrelated route change.
+   *
+   * The immediate tick exists because leaving a frozen page should show
+   * current data at once. It is NOT fired when the operator has auto-refresh
+   * off: their switch decides, and honouring it is the whole point of keeping
+   * `enabled` separate from suspension.
+   */
+  function resume(reason = 'route'): void {
+    if (!suspendedBy.value.has(reason)) return;
+    const next = new Set(suspendedBy.value);
+    next.delete(reason);
+    suspendedBy.value = next;
+    if (next.size > 0) return;
+    if (enabled.value) void runRound('resume');
   }
 
+  // Three independent conditions, all of which must hold. `tabVisible` was
+  // missing, so a backgrounded tab reported itself as refreshing and the
+  // countdown drained toward a round the visibility handler had already
+  // stopped from being armed.
   const effectiveEnabled = computed(
-    () => intervalSec.value !== null && !suspended.value,
-  );
-  const nextTickAt = computed(() =>
-    intervalSec.value === null ? null : lastTickAt.value + intervalSec.value * 1000,
+    () => enabled.value && suspendedBy.value.size === 0 && tabVisible.value,
   );
+  // From the END of the last round, which is what "counts down after the last
+  // round completed" means: during a slow round the countdown is not describing
+  // a deadline that has already passed.
+  const nextTickAt = computed(() => nextRunAt.value);
   const secondsUntilNext = computed(() => {
+    // Nothing to count while a round is out: the next one starts when THIS one
+    // ends, and that instant does not exist yet. Reporting a number here — the
+    // interval, or a countdown from the previous round's end — states a
+    // deadline nobody can hold the page to. The chip says "Refreshing" instead.
+    if (roundRunning.value || busy.value) return null;
     const next = nextTickAt.value;
-    if (next === null || suspended.value) return null;
+    if (next === null || !effectiveEnabled.value) return null;
     return Math.max(0, Math.round((next - nowMs.value) / 1000));
   });
 
   onScopeDispose(() => {
+    stopWatchingTraffic();
     clearMainTimer();
     if (countdownId) {
       clearInterval(countdownId);
@@ -158,12 +544,20 @@ export const useAutoRefreshStore = defineStore('auto-refresh', () => {
 
   return {
     intervalSec,
+    enabled,
     tickCount,
     suspended,
+    roundRunning,
+    currentRound,
+    whenIdle,
+    onRoundCapped,
     effectiveEnabled,
     secondsUntilNext,
     setInterval: setInterval_,
+    setEnabled,
+    toggleEnabled,
     refreshNow,
+    joinRound,
     suspend,
     resume,
   };
diff --git a/apps/ui/src/controls/coldStage.test.ts b/apps/ui/src/controls/coldStage.test.ts
new file mode 100644
index 00000000..b21eee49
--- /dev/null
+++ b/apps/ui/src/controls/coldStage.test.ts
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Flipping the stage has to be ONE event.
+ *
+ * Cold REPLACES the hot+warm read rather than widening it, so a page showing
+ * both at once is not a partial answer — it is two different questions'
+ * answers on one screen, with nothing on it saying which is which. Every
+ * assertion here is about the flip being indivisible: the header cannot lag
+ * the key, and nothing may refetch before the round that re-reads the page.
+ */
+
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+
+const cancelQueries = vi.fn(() => Promise.resolve());
+const invalidateQueries = vi.fn(() => Promise.resolve());
+const refetchQueries = vi.fn((_opts?: unknown) => Promise.resolve());
+// The auto-refresh store watches the cache to know when the page is busy, so
+// the double has to carry that surface too — a partial double fails as a crash
+// inside the store rather than as a missing assertion.
+vi.mock('@/api/queryClient', () => ({
+  queryClient: {
+    cancelQueries,
+    invalidateQueries,
+    refetchQueries,
+    isFetching: () => 0,
+    getQueryCache: () => ({ subscribe: () => () => {}, getAll: () => [] }),
+  },
+}));
+
+const { useColdStageStore, readColdStageHeader } = await import('./coldStage');
+const { useAutoRefreshStore } = await import('./autoRefresh');
+
+beforeEach(() => {
+  setActivePinia(createPinia());
+  localStorage.clear();
+  invalidateQueries.mockClear();
+  cancelQueries.mockClear();
+  refetchQueries.mockClear();
+});
+
+describe('the flip is atomic', () => {
+  // The defect this replaced: the flag was persisted by a watcher, so between
+  // the store flipping and localStorage being written there was a window in
+  // which a request carried the OLD header while its answer was filed under
+  // the NEW key. A Cold answer under a Hot key is undetectable afterwards.
+  it('writes the header in the same tick as the flag, with no await between', () => {
+    const cold = useColdStageStore();
+
+    cold.set(true);
+
+    expect(cold.enabled).toBe(true);
+    expect(readColdStageHeader(), 'the request header lagged the cache key').toBe(true);
+  });
+
+  it('flips back the same way', () => {
+    const cold = useColdStageStore();
+    cold.set(true);
+
+    cold.set(false);
+
+    expect(readColdStageHeader()).toBe(false);
+  });
+
+  it('does nothing at all when set to the value it already has', () => {
+    const cold = useColdStageStore();
+    cold.set(false);
+
+    expect(invalidateQueries, 'a no-op flip refetched the page').not.toHaveBeenCalled();
+  });
+});
+
+describe('the refetch is a round, not a storm', () => {
+  it('cancels what is in flight, so nothing straddles the flip', () => {
+    // The stage is a header read when a request GOES OUT, so a request queued
+    // behind the concurrency limiter would leave under the new stage while
+    // belonging to the old round — landing a cold answer in a hot entry.
+    useColdStageStore().set(true);
+
+    expect(cancelQueries, 'an in-flight read could span both stages').toHaveBeenCalled();
+  });
+
+  it('marks everything stale WITHOUT refetching it', () => {
+    const cold = useColdStageStore();
+
+    cold.set(true);
+
+    expect(invalidateQueries).toHaveBeenCalledWith({ refetchType: 'none' });
+  });
+
+  it('runs one round, so the page is re-read together', () => {
+    const auto = useAutoRefreshStore();
+    const before = auto.tickCount;
+
+    useColdStageStore().set(true);
+
+    expect(auto.tickCount, 'the flip did not go through a round').toBe(before + 1);
+  });
+
+  it('re-reads what the round did not cover, and only that', async () => {
+    const cold = useColdStageStore();
+
+    cold.set(true);
+    await new Promise((r) => setTimeout(r, 0));
+
+    // Selected by WHEN the data was fetched, not by staleness. Round-managed
+    // queries carry no freshness window, so `stale: true` would have selected
+    // every one of them and turned this into the second storm it exists to
+    // avoid.
+    const arg = refetchQueries.mock.calls.at(-1)?.[0] as
+      | { type?: string; predicate?: (q: unknown) => boolean }
+      | undefined;
+    expect(arg?.type).toBe('active');
+    expect(typeof arg?.predicate, 'the sweep selected by staleness again').toBe('function');
+    // Answered after the flip ⇒ already the new stage ⇒ left alone.
+    expect(arg?.predicate?.({ state: { dataUpdatedAt: Date.now() + 5_000 } })).toBe(false);
+    // Answered before it ⇒ still the old stage ⇒ re-read.
+    expect(arg?.predicate?.({ state: { dataUpdatedAt: 0 } })).toBe(true);
+  });
+
+  it('waits for the TRAILING round when the flip lands mid-round', async () => {
+    // The coalescing case. A flip while a round is out does not start a round
+    // of its own — it queues one — so awaiting the call would have resolved
+    // before the successor had even begun, and the leftovers would have been
+    // re-read against the stage that was on the way out.
+    const auto = useAutoRefreshStore();
+    let release!: () => void;
+    let rounds = 0;
+    auto.joinRound(() => {
+      rounds += 1;
+      return rounds === 1 ? new Promise<void>((r) => { release = r; }) : undefined;
+    });
+    void auto.refreshNow();
+    await Promise.resolve();
+
+    useColdStageStore().set(true);
+    // A full flush, not one microtask: awaiting the flip's own call would
+    // resolve within a few ticks, and a shorter wait could not tell that apart
+    // from correctly waiting for the successor.
+    await new Promise((r) => setTimeout(r, 0));
+    expect(refetchQueries, 'the leftovers were re-read against the outgoing stage').not.toHaveBeenCalled();
+
+    release();
+    await new Promise((r) => setTimeout(r, 0));
+
+    expect(rounds, 'the flip did not queue a round of its own').toBe(2);
+    expect(refetchQueries).toHaveBeenCalled();
+  });
+
+  it('does not re-read the leftovers until the round has finished', async () => {
+    const auto = useAutoRefreshStore();
+    let release!: () => void;
+    auto.joinRound(() => new Promise<void>((r) => { release = r; }));
+
+    useColdStageStore().set(true);
+    await Promise.resolve();
+
+    expect(
+      refetchQueries,
+      'the leftovers were re-read while the round was still out',
+    ).not.toHaveBeenCalled();
+
+    release();
+  });
+});
diff --git a/apps/ui/src/controls/coldStage.ts b/apps/ui/src/controls/coldStage.ts
index 90b420d7..b800a73d 100644
--- a/apps/ui/src/controls/coldStage.ts
+++ b/apps/ui/src/controls/coldStage.ts
@@ -42,19 +42,41 @@
  *
  * The setting is sticky per browser (localStorage) so an operator
  * deep in a cold investigation doesn't lose context on reload.
- * Flipping the toggle invalidates every cached query so subscribers
- * refetch with the new header instead of serving the previous stage's
- * data.
+ *
+ * Flipping it is ATOMIC and goes through a round. Two things had to be true
+ * for that to be safe, and neither was:
+ *
+ * - The header and the cache key must flip TOGETHER. The key is built from
+ *   this store while the header is read from localStorage when the request
+ *   goes out, so persisting on a watcher left a window in which a Cold answer
+ *   could be filed under the Hot key. The write is synchronous with the flip
+ *   now, in the same tick.
+ * - The refetch must be a ROUND, not a scattergun. Invalidating every cached
+ *   query fired a page-wide storm outside any round: uncounted by the
+ *   countdown, able to land on top of a round already out, and arriving screen
+ *   by screen so the page showed Hot and Cold answers side by side while it
+ *   settled. Everything is marked stale WITHOUT refetching, then one round
+ *   re-reads what is on screen, together.
  */
 
 import { defineStore } from 'pinia';
-import { ref, watch } from 'vue';
-import type { QueryClient } from '@tanstack/vue-query';
+import { ref } from 'vue';
+import { queryClient } from '@/api/queryClient';
+import { useAutoRefreshStore } from '@/controls/autoRefresh';
 
 export const COLD_STAGE_HEADER = 'X-Horizon-Cold-Stage';
 
 const STORAGE_KEY = 'horizon:coldStage:v1';
 
+/**
+ * The stage, cached in memory.
+ *
+ * Read on every request AND on every cache-key hash, so it must not be a
+ * localStorage round-trip each time. Written by `persist` in the same tick as
+ * the flag, which is what keeps the header and the key describing one stage.
+ */
+let currentStage: boolean | null = null;
+
 function detectInitial(): boolean {
   if (typeof localStorage !== 'undefined') {
     const raw = localStorage.getItem(STORAGE_KEY);
@@ -66,30 +88,77 @@ function detectInitial(): boolean {
 export const useColdStageStore = defineStore('cold-stage', () => {
   const enabled = ref<boolean>(detectInitial());
 
-  // Persist on every change so the next page-load picks up where we
-  // left off. localStorage may be unavailable (private mode) — fall
-  // back to in-memory and let it reset on reload.
-  watch(enabled, (on) => {
+  /** Written in the same tick as the flag — see the header. localStorage may
+   *  be unavailable (private mode); fall back to in-memory and let it reset on
+   *  reload. */
+  function persist(on: boolean): void {
+    currentStage = on;
     if (typeof localStorage === 'undefined') return;
     try {
       localStorage.setItem(STORAGE_KEY, on ? '1' : '0');
     } catch {
       /* private mode / quota — degrade silently */
     }
-  });
-
-  /** Flip the flag. When `client` is passed (which the topbar does on
-   *  every toggle), every cached query is invalidated so subscribers
-   *  refetch with the new header — otherwise the cache would serve the
-   *  previous warm-only payload until the next time-range change. */
-  function toggle(client?: QueryClient): void {
-    enabled.value = !enabled.value;
-    client?.invalidateQueries();
   }
-  function set(on: boolean, client?: QueryClient): void {
+
+  /**
+   * Flip the stage.
+   *
+   * The client is the store's own, not the caller's. Passing it was a
+   * requirement every call site had to remember, and the trap banner's
+   * "turn it off" did not — so the banner cleared the flag while every screen
+   * kept rendering the cold answers it had already cached.
+   */
+  function set(on: boolean): void {
     if (enabled.value === on) return;
+    const flippedAt = Date.now();
     enabled.value = on;
-    client?.invalidateQueries();
+    persist(on);
+    // CANCEL what is in flight first, and this is the ONLY thing keeping a
+    // read from straddling the flip.
+    //
+    // The stage rides on every request as a header read when the request goes
+    // out, so anything already queued behind the concurrency limiter would
+    // otherwise leave carrying the NEW stage while belonging to the round that
+    // asked under the old one, and land its answer in the other stage's entry.
+    //
+    // Scoping the cache by stage was tried instead and is WRONG: a query's hash
+    // is computed when it is created, and existing observers do not rebind when
+    // a global flips — so a response could be stored under the old hash while
+    // every lookup used the new one, which breaks isolation, deduplication and
+    // exact cancellation at once. Cancelling has none of that ambiguity.
+    void queryClient.cancelQueries();
+    // Marked stale WITHOUT refetching, so nothing starts before the round
+    // does. A query whose key already carries the stage has moved to a
+    // different cache entry anyway.
+    void queryClient.invalidateQueries({ refetchType: 'none' });
+    const auto = useAutoRefreshStore();
+    // Named for what it IS. Reported as 'manual' the failure history said the
+    // operator had asked for a refresh, when what they did was change the
+    // stage — two different things to be looking at when a round fails.
+    void auto.refreshNow('cold-stage');
+    // Whatever the round did not cover — an explore page with its own controls,
+    // which subscribes to no round — is still showing the other stage's
+    // answers, so it is re-read once the page has settled.
+    //
+    // Selected by WHEN the data was fetched, not by staleness. Round-managed
+    // queries hold no freshness window, so they are stale the instant they
+    // land and `stale: true` selected every one of them — turning the sweep
+    // into the second storm it exists to avoid. Anything answered after the
+    // flip already reflects the new stage and is left alone.
+    //
+    // `whenIdle`, not the round's own promise: flipping the stage while a round
+    // was already out coalesces into a trailing round, and awaiting the call
+    // would have resolved before that successor had even started.
+    void auto.whenIdle().then(() =>
+      queryClient.refetchQueries({
+        type: 'active',
+        predicate: (q) => q.state.dataUpdatedAt < flippedAt,
+      }),
+    );
+  }
+  function toggle(): void {
+    set(!enabled.value);
   }
 
   return { enabled, toggle, set };
@@ -100,9 +169,11 @@ export const useColdStageStore = defineStore('cold-stage', () => {
  *  Reads localStorage directly so the value is fresh even when the
  *  Pinia store hasn't been instantiated yet (early bootstrap). */
 export function readColdStageHeader(): boolean {
+  if (currentStage !== null) return currentStage;
   if (typeof localStorage === 'undefined') return false;
   try {
-    return localStorage.getItem(STORAGE_KEY) === '1';
+    currentStage = localStorage.getItem(STORAGE_KEY) === '1';
+    return currentStage;
   } catch {
     return false;
   }
diff --git a/apps/ui/src/controls/configBundle.ts b/apps/ui/src/controls/configBundle.ts
index 41e6f496..436dafc6 100644
--- a/apps/ui/src/controls/configBundle.ts
+++ b/apps/ui/src/controls/configBundle.ts
@@ -231,6 +231,45 @@ export async function refreshConfigBundle(opts: { force?: boolean } = {}): Promi
   }
 }
 
+/**
+ * How often the bundle is re-read while the app is open.
+ *
+ * Sixty seconds, which is the upper bound on how long a template pushed
+ * somewhere else — another Horizon, swctl, any tool writing to the same OAP —
+ * takes to appear in a running UI. It is also what lets an unreachable store
+ * clear itself once it recovers.
+ *
+ * Cheap by construction: the request carries `If-None-Match`, so an unchanged
+ * store answers 304, and the BFF's own thirty-second cache bounds how often
+ * this reaches OAP at all.
+ */
+const SYNC_PERIOD_MS = 60_000;
+let syncTimer: ReturnType<typeof setInterval> | null = null;
+
+/**
+ * Start the periodic re-read. Called once, by the shell.
+ *
+ * Deliberately NOT part of the refresh round: this is static configuration,
+ * not page data — putting it in the round would spend a request per page per
+ * cycle re-reading an unchanged document, and would put a config failure in
+ * the round's failure history where it reads as a data problem. It does not go
+ * through the query cache either, so it cannot hold the coordinated timer the
+ * way an independent poller otherwise would.
+ */
+export function startConfigBundleSync(): void {
+  if (syncTimer !== null) return;
+  syncTimer = setInterval(() => {
+    if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
+    void refreshConfigBundle();
+  }, SYNC_PERIOD_MS);
+}
+
+export function stopConfigBundleSync(): void {
+  if (syncTimer === null) return;
+  clearInterval(syncTimer);
+  syncTimer = null;
+}
+
 /** Set the global local-vs-remote render preference and re-pull the
  *  bundle so every dashboard re-renders from the chosen source. */
 export async function setTemplateRenderMode(mode: 'local' | 'remote'): Promise<void> {
diff --git a/apps/ui/src/controls/configBundleSync.test.ts b/apps/ui/src/controls/configBundleSync.test.ts
new file mode 100644
index 00000000..55e75590
--- /dev/null
+++ b/apps/ui/src/controls/configBundleSync.test.ts
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Templates change from outside this browser.
+ *
+ * Another Horizon, swctl, or anything else writing the same OAP store can
+ * publish a dashboard, and a store that could not be read can come back.
+ * Neither reached a running UI: the bundle was fetched once at mount and never
+ * again, so a change appeared only after a reload and an unreachable banner
+ * stayed up however healthy OAP had become.
+ */
+
+import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
+
+const bundle = vi.fn(async () => null);
+vi.mock('@/api/client', () => ({ bffClient: { configs: { bundle } } }));
+
+const { startConfigBundleSync, stopConfigBundleSync } = await import('./configBundle');
+
+beforeEach(() => {
+  vi.useFakeTimers();
+  bundle.mockClear();
+});
+afterEach(() => {
+  stopConfigBundleSync();
+  vi.useRealTimers();
+});
+
+describe('the periodic template re-read', () => {
+  it('re-reads about once a minute', async () => {
+    startConfigBundleSync();
+    expect(bundle, 'it fetched immediately — the shell already did that on mount').not.toHaveBeenCalled();
+
+    await vi.advanceTimersByTimeAsync(60_000);
+    expect(bundle).toHaveBeenCalledTimes(1);
+
+    await vi.advanceTimersByTimeAsync(60_000);
+    expect(bundle).toHaveBeenCalledTimes(2);
+  });
+
+  it('starts once, however many times it is asked to', async () => {
+    startConfigBundleSync();
+    startConfigBundleSync();
+    startConfigBundleSync();
+
+    await vi.advanceTimersByTimeAsync(60_000);
+
+    expect(bundle, 'a second call added a second timer').toHaveBeenCalledTimes(1);
+  });
+
+  it('skips a hidden tab — nobody is looking at those templates', async () => {
+    Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true });
+    startConfigBundleSync();
+
+    await vi.advanceTimersByTimeAsync(180_000);
+
+    expect(bundle).not.toHaveBeenCalled();
+    Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true });
+  });
+
+  it('stops when told to', async () => {
+    startConfigBundleSync();
+    await vi.advanceTimersByTimeAsync(60_000);
+    expect(bundle).toHaveBeenCalledTimes(1);
+
+    stopConfigBundleSync();
+    await vi.advanceTimersByTimeAsync(180_000);
+
+    expect(bundle).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/apps/ui/src/controls/errorCenter.test.ts b/apps/ui/src/controls/errorCenter.test.ts
new file mode 100644
index 00000000..790870a6
--- /dev/null
+++ b/apps/ui/src/controls/errorCenter.test.ts
@@ -0,0 +1,373 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Two questions, and both are answered wrong by the obvious implementation:
+ * WHERE a failure goes, and WHAT of it may be shown.
+ *
+ * Routing is not cosmetic. A background failure shown as a toast trains people
+ * to dismiss without reading, and an operator's own failed click hidden in a
+ * panel they never open reads as the click doing nothing.
+ *
+ * Redaction is tested rather than assumed because the failure mode is silent:
+ * a token in a query string renders perfectly, and nobody notices until the
+ * screenshot is already in a ticket.
+ */
+
+import { describe, expect, it, beforeEach } from 'vitest';
+import { ref } from 'vue';
+import { createPinia, setActivePinia } from 'pinia';
+import {
+  REFRESH_HISTORY,
+  describeFailure,
+  redactBody,
+  redactUrl,
+  useErrorCenterStore,
+  useRefreshErrorReport,
+  reportActionFailure,
+} from './errorCenter';
+import { useAutoRefreshStore } from './autoRefresh';
+
+beforeEach(() => setActivePinia(createPinia()));
+
+const record = (scope: 'refresh' | 'component', summary = 'boom') => ({
+  scope,
+  owner: 'Service map',
+  action: 'reading the service map',
+  summary,
+});
+
+describe('where a failure goes', () => {
+  it('sends a refresh failure to the history, never to a toast', () => {
+    const c = useErrorCenterStore();
+    c.record(record('refresh'));
+
+    expect(c.refreshHistory).toHaveLength(1);
+    expect(c.toasts, 'a background failure interrupted the operator').toHaveLength(0);
+  });
+
+  it('sends an operator’s failure to a toast, never to the history', () => {
+    const c = useErrorCenterStore();
+    c.record(record('component'));
+
+    expect(c.toasts).toHaveLength(1);
+    expect(c.refreshHistory, 'a click’s outcome was buried in a panel').toHaveLength(0);
+  });
+
+  it('keeps only the newest five refresh failures', () => {
+    const c = useErrorCenterStore();
+    for (let i = 0; i < REFRESH_HISTORY + 3; i += 1) c.record(record('refresh', `boom ${i}`));
+
+    expect(c.refreshHistory).toHaveLength(REFRESH_HISTORY);
+    expect(c.refreshHistory[0]?.summary, 'the list is oldest-first').toBe('boom 7');
+  });
+
+  it('caps the unread count at what the list can actually show', () => {
+    const c = useErrorCenterStore();
+    for (let i = 0; i < 20; i += 1) c.record(record('refresh'));
+
+    expect(c.unreadCount).toBe(REFRESH_HISTORY);
+  });
+
+  it('clears the badge on read but KEEPS the records', () => {
+    const c = useErrorCenterStore();
+    c.record(record('refresh'));
+
+    c.markRead();
+
+    expect(c.hasUnread).toBe(false);
+    expect(c.refreshHistory, 'reading the panel destroyed what it was showing').toHaveLength(1);
+  });
+});
+
+describe('what may be shown', () => {
+  it('removes secret query values and keeps their names', () => {
+    const out = redactUrl('/api/layer/general/topology?token=abc123&depth=2');
+
+    expect(out).not.toContain('abc123');
+    expect(out, 'the parameter’s NAME is useful and was dropped with its value').toContain('token');
+    expect(out).toContain('depth=2');
+  });
+
+  it('redacts every spelling a secret arrives under', () => {
+    for (const key of ['password', 'authorization', 'api_key', 'accessToken', 'client_secret']) {
+      expect(redactUrl(`/x?${key}=s3cr3t`), key).not.toContain('s3cr3t');
+    }
+  });
+
+  it('redacts a secret nested deep in a body, not just at the top', () => {
+    const out = redactBody({ data: { user: { name: 'ann', password: 'hunter2' } } });
+
+    expect(out).not.toContain('hunter2');
+    expect(out, 'redaction took the surrounding data with it').toContain('ann');
+  });
+
+  it('redacts a JSON body that arrived as a string', () => {
+    expect(redactBody('{"token":"abc123"}')).not.toContain('abc123');
+  });
+
+  it('shows a non-JSON string body as it is', () => {
+    expect(redactBody('upstream timed out')).toBe('upstream timed out');
+  });
+
+  it('caps a body rather than pasting a megabyte into the panel', () => {
+    const out = redactBody('x'.repeat(50_000));
+
+    expect(out.length).toBeLessThan(2200);
+    expect(out, 'truncation was silent — the reader would think that was all of it').toContain(
+      'truncated',
+    );
+  });
+
+  it('does not recurse for ever on a self-referential body', () => {
+    const loop: Record<string, unknown> = { name: 'a' };
+    loop.self = loop;
+
+    expect(() => redactBody(loop)).not.toThrow();
+  });
+});
+
+describe('describing a failure', () => {
+  it('never renders an unknown throw as "[object Object]"', () => {
+    const out = describeFailure({ weird: true }, 'Service map', 'reading', 'refresh');
+
+    expect(out.summary).not.toContain('object Object');
+    expect(out.summaryKey, 'our own sentence must be translatable').toBeTruthy();
+  });
+
+  it('keeps a plain error’s own words', () => {
+    const out = describeFailure(new Error('upstream timed out'), 'X', 'y', 'component');
+
+    expect(out.summary).toBe('upstream timed out');
+    expect(out.summaryKey, 'the server’s words were replaced by ours').toBeUndefined();
+  });
+});
+
+describe('the toast queue', () => {
+  it('dismisses one without touching the others', () => {
+    const c = useErrorCenterStore();
+    c.record(record('component', 'first'));
+    c.record(record('component', 'second'));
+
+    c.dismissToast(c.toasts[0]!.id);
+
+    expect(c.toasts).toHaveLength(1);
+    expect(c.toasts[0]?.summary).toBe('second');
+  });
+
+  it('clears everything on reset — one session’s failures are not another’s', () => {
+    const c = useErrorCenterStore();
+    c.record(record('component'));
+    c.record(record('refresh'));
+
+    c.reset();
+
+    expect(c.toasts).toHaveLength(0);
+    expect(c.refreshHistory).toHaveLength(0);
+    expect(c.unreadCount).toBe(0);
+  });
+});
+
+describe('a record is a snapshot, not a live view', () => {
+  it('does not change when the error it was made from does', () => {
+    const c = useErrorCenterStore();
+    const err = ref(new Error('first'));
+    c.record(describeFailure(err.value, 'X', 'y', 'refresh'));
+
+    err.value = new Error('second');
+
+    expect(c.refreshHistory[0]?.summary).toBe('first');
+  });
+});
+
+describe('attributing a failure to the round that caused it', () => {
+  const failing = () => {
+    const err = ref<Error | null>(null);
+    useRefreshErrorReport({ owner: 'Service map', action: 'reading the service map', error: err });
+    return err;
+  };
+
+  it('records a failure that surfaces while a round is out', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const err = failing();
+    // Fails the way a query does: its error state settles INSIDE the round.
+    auto.joinRound(() => { err.value = new Error('upstream timed out'); });
+
+    await auto.refreshNow();
+
+    expect(c.refreshHistory).toHaveLength(1);
+    expect(c.refreshHistory[0]?.trigger, 'the round’s trigger was not carried').toBe('manual');
+    expect(c.refreshHistory[0]?.roundId).toBeGreaterThan(0);
+  });
+
+  // A first load that fails already SAYS SO where the graph would have been.
+  // Repeating it in a panel the operator has to open is the same news twice.
+  it('ignores a failure outside any round', () => {
+    const c = useErrorCenterStore();
+    const err = failing();
+
+    err.value = new Error('upstream timed out');
+
+    expect(c.refreshHistory).toHaveLength(0);
+  });
+
+  it('does not repeat one sentence for every cycle of the same outage', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const err = failing();
+    auto.joinRound(() => { err.value = new Error('upstream timed out'); });
+
+    // A second failing cycle: a NEW error object carrying the same sentence,
+    // which is what a query produces when the backend is simply still down.
+    // It never passes through null, so this is not a recovery.
+    await auto.refreshNow();
+
+    expect(c.refreshHistory, 'an outage papered the history with one repeated line').toHaveLength(1);
+  });
+
+  it('reports again once it recovered and failed anew', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const err = failing();
+    let attempt = 0;
+    auto.joinRound(() => {
+      attempt += 1;
+      // Fail, recover, then fail with the SAME words — which is news again.
+      err.value = attempt === 2 ? null : new Error('upstream timed out');
+    });
+
+    await auto.refreshNow();
+    await auto.refreshNow();
+    await auto.refreshNow();
+
+    expect(c.refreshHistory).toHaveLength(2);
+  });
+});
+
+describe('an operator who asked gets an answer, once', () => {
+  const failing = () => {
+    const err = ref<Error | null>(null);
+    useRefreshErrorReport({ owner: 'Service map', action: 'reading the service map', error: err });
+    return err;
+  };
+
+  // The contradiction this removes: clicking Refresh against a dead backend
+  // filed the failure in a background history and showed nothing, even though
+  // the operator had just pressed the button and was watching.
+  it('says so on screen when the round was manual, and still records it', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const err = failing();
+    auto.joinRound(() => { err.value = new Error('upstream timed out'); });
+
+    await auto.refreshNow();
+
+    expect(c.refreshHistory, 'the failure left no record').toHaveLength(1);
+    expect(c.toasts, 'the operator pressed Refresh and was shown nothing').toHaveLength(1);
+  });
+
+  it('says it ONCE, however many screens failed', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const a = ref<Error | null>(null);
+    const b = ref<Error | null>(null);
+    useRefreshErrorReport({ owner: 'Service map', action: 'reading the service map', error: a });
+    useRefreshErrorReport({ owner: 'Deployment', action: 'reading the deployment graph', error: b });
+    auto.joinRound(() => {
+      a.value = new Error('upstream timed out');
+      b.value = new Error('upstream timed out');
+    });
+
+    await auto.refreshNow();
+
+    expect(c.refreshHistory, 'each screen should have its own entry').toHaveLength(2);
+    expect(c.toasts, 'an outage buried the screen in toasts saying the same thing').toHaveLength(1);
+  });
+
+  it('stays silent for a round the TIMER ran', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const err = failing();
+    auto.joinRound(() => { err.value = new Error('upstream timed out'); });
+
+    // A trailing 'auto' round is what the timer produces.
+    await auto.refreshNow('auto');
+
+    expect(c.refreshHistory).toHaveLength(1);
+    expect(c.toasts, 'a background round interrupted the operator').toHaveLength(0);
+  });
+});
+
+describe('one event, one record', () => {
+  // Two screens sharing a query both see the same transport failure; the
+  // history showed one outage as several.
+  it('records a repeated report from a second observer only once', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const a = ref<Error | null>(null);
+    const b = ref<Error | null>(null);
+    // Same owner and action — the same query, watched twice.
+    useRefreshErrorReport({ owner: 'Layer landing', action: 'reading the layer landing metrics', error: a });
+    useRefreshErrorReport({ owner: 'Layer landing', action: 'reading the layer landing metrics', error: b });
+    auto.joinRound(() => {
+      a.value = new Error('upstream timed out');
+      b.value = new Error('upstream timed out');
+    });
+
+    await auto.refreshNow();
+
+    expect(c.refreshHistory, 'one outage was listed twice').toHaveLength(1);
+  });
+});
+
+describe('a cancellation is not a failure', () => {
+  it('is never recorded — we stopped it', () => {
+    const c = useErrorCenterStore();
+    const aborted = new Error('aborted');
+    aborted.name = 'AbortError';
+
+    reportActionFailure(aborted, 'Endpoint dependency', 'expanding an endpoint');
+
+    expect(c.toasts, 'the app’s own decision was reported as an outage').toHaveLength(0);
+  });
+});
+
+describe('a manual retry during the same outage', () => {
+  // The suppression exists for the TIMER — an outage fails identically every
+  // cycle. Applied to the operator's own retry it reproduced the silence the
+  // toast was added to remove.
+  it('still answers on screen, and does not duplicate the record', async () => {
+    const c = useErrorCenterStore();
+    const auto = useAutoRefreshStore();
+    const err = ref<Error | null>(null);
+    useRefreshErrorReport({ owner: 'Service map', action: 'reading the service map', error: err });
+    auto.joinRound(() => { err.value = new Error('upstream timed out'); });
+
+    // The timer sees it first: recorded, no toast.
+    await auto.refreshNow('auto');
+    expect(c.refreshHistory).toHaveLength(1);
+    expect(c.toasts).toHaveLength(0);
+
+    // The operator retries against the same dead backend.
+    err.value = new Error('upstream timed out');
+    await auto.refreshNow('manual');
+
+    expect(c.toasts, 'the operator pressed Refresh again and was shown nothing').toHaveLength(1);
+    expect(c.refreshHistory, 'the same outage was listed twice').toHaveLength(1);
+  });
+});
diff --git a/apps/ui/src/controls/errorCenter.ts b/apps/ui/src/controls/errorCenter.ts
new file mode 100644
index 00000000..968a337a
--- /dev/null
+++ b/apps/ui/src/controls/errorCenter.ts
@@ -0,0 +1,428 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Where a failure goes, and who decides.
+ *
+ * One store, two audiences, because a failure means different things depending
+ * on what asked for it:
+ *
+ * - **refresh** — something the TIMER did. The operator did not ask for it and
+ *   may not be looking, so it lands in a short history beside the refresh
+ *   button and waits to be read. A background failure that popped a toast
+ *   every cycle would paper the screen during an outage and train people to
+ *   dismiss without reading.
+ * - **component** — something the operator just DID: expanded a node, saved a
+ *   rule. They are looking at it and expecting an answer, so it is shown at
+ *   once and then gets out of the way.
+ *
+ * The API layer only CONSTRUCTS records; it never displays them. Reporting at
+ * the fetch layer looked tempting and is wrong twice over: a query library
+ * retries, so one failure would be reported several times, and a component
+ * that catches an error to handle it would still have had it announced.
+ */
+
+import { computed, ref, toValue, watch } from 'vue';
+import type { MaybeRefOrGetter, Ref } from 'vue';
+import { defineStore } from 'pinia';
+import { useAutoRefreshStore } from './autoRefresh';
+import { onSessionReset } from '@/state/sessionReset';
+import { BffApiError, describeApiError } from '@/api/client';
+import { GraphUnavailableError } from '@/layer/graphQuery';
+
+/** How many refresh failures are kept. A ring, not a log: an outage produces
+ *  one failure per cycle, and the fifth-oldest tells nobody anything the
+ *  newest does not. */
+export const REFRESH_HISTORY = 5;
+
+export interface UiErrorRecord {
+  id: string;
+  occurredAt: number;
+  scope: 'refresh' | 'component';
+  /** What set the round off, for a refresh failure. */
+  trigger?: string;
+  roundId?: number;
+  /**
+   * The screen or feature that asked — "Service map", "Alarms widget".
+   *
+   * A fixed literal from our own code, never OAP data, so the card translates
+   * it at render time. That way a record written before a locale switch still
+   * reads in the language the operator is now using.
+   */
+  owner: string;
+  /** What it was trying to do, in the operator's words. Same rule as `owner`. */
+  action: string;
+  method?: string;
+  /** Redacted before it ever reaches here. */
+  url?: string;
+  /** 0 when the request never got a reply. */
+  status?: number;
+  /**
+   * What happened, VERBATIM from the server or the network.
+   *
+   * Never translated: it is the upstream's own words, and paraphrasing an
+   * OAP error into another language would make it unsearchable against OAP's
+   * logs. Where the sentence is OURS rather than the server's, `summaryKey`
+   * carries it instead and the card translates that.
+   */
+  summary: string;
+  /** Our own explanation, as a translatable key. Takes precedence over
+   *  `summary` when present. */
+  summaryKey?: string;
+  detail?: string;
+  responseBody?: string;
+}
+
+/** Query and fragment values that must never be displayed. */
+const SECRET_PARAM = /^(token|password|passwd|secret|authorization|auth|cookie|api[-_]?key|access[-_]?token|refresh[-_]?token|client[-_]?secret|sig|signature)$/i;
+/** Object keys that must never be displayed, at any depth. */
+const SECRET_KEY = SECRET_PARAM;
+const REDACTED = '[redacted]';
+/** Response bodies are evidence, not a payload dump. */
+const MAX_BODY = 2000;
+
+/**
+ * A URL with its secrets removed.
+ *
+ * Values go, names stay: knowing a request carried a `token` is useful, and
+ * the value never is. A string that will not parse is returned as its path
+ * only, because a malformed URL is not worth guessing at.
+ */
+export function redactUrl(raw: string): string {
+  try {
+    const u = new URL(raw, 'http://x');
+    for (const key of [...u.searchParams.keys()]) {
+      if (SECRET_PARAM.test(key)) u.searchParams.set(key, REDACTED);
+    }
+    return `${u.pathname}${u.search}`;
+  } catch {
+    return raw.split('?')[0] ?? '';
+  }
+}
+
+/**
+ * A response body reduced to something safe to show.
+ *
+ * Recursive, because a secret nested three levels down is exactly as exposed
+ * as one at the top. Length-capped, because a body is shown to explain a
+ * failure and a megabyte of JSON explains nothing.
+ */
+export function redactBody(body: unknown): string {
+  const walk = (v: unknown, depth: number): unknown => {
+    if (depth > 6) return '…';
+    if (Array.isArray(v)) return v.slice(0, 50).map((x) => walk(x, depth + 1));
+    if (v && typeof v === 'object') {
+      const out: Record<string, unknown> = {};
+      for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
+        out[k] = SECRET_KEY.test(k) ? REDACTED : walk(val, depth + 1);
+      }
+      return out;
+    }
+    return v;
+  };
+  let text: string;
+  try {
+    text = typeof body === 'string' ? body : JSON.stringify(walk(body, 0), null, 2);
+  } catch {
+    text = String(body);
+  }
+  if (typeof body === 'string') {
+    // A string body may still be JSON — redact it if it parses, otherwise show
+    // it as-is rather than guessing at its shape.
+    try {
+      text = JSON.stringify(walk(JSON.parse(body), 0), null, 2);
+    } catch {
+      /* not JSON; the raw text stands */
+    }
+  }
+  return text.length > MAX_BODY ? `${text.slice(0, MAX_BODY)}\n… (truncated)` : text;
+}
+
+let nextId = 0;
+
+/** Triggers the operator is responsible for, and therefore watching. */
+const OPERATOR_TRIGGERS = new Set(['manual', 'time-change', 'cold-stage']);
+
+export const useErrorCenterStore = defineStore('error-center', () => {
+  /** Newest first. Capped at `REFRESH_HISTORY`. */
+  const refreshHistory = ref<UiErrorRecord[]>([]);
+  /** Records not yet looked at — what the badge counts. */
+  const unreadCount = ref(0);
+  /** Currently on screen as a toast, newest last. */
+  const toasts = ref<UiErrorRecord[]>([]);
+
+  function record(input: Omit<UiErrorRecord, 'id' | 'occurredAt'>): UiErrorRecord | null {
+    // One EVENT, one record. A query with two observers — two screens sharing a
+    // roster, a widget mounted twice — reports the same transport failure from
+    // each of them, and the history then shows one outage as several. The round
+    // is what makes them the same event: same round, same owner, same action,
+    // same words.
+    if (input.scope === 'refresh' && input.roundId !== undefined) {
+      const already = refreshHistory.value.some(
+        (r) =>
+          r.roundId === input.roundId &&
+          r.owner === input.owner &&
+          r.action === input.action &&
+          r.summary === input.summary,
+      );
+      if (already) return null;
+    }
+    nextId += 1;
+    const rec: UiErrorRecord = { ...input, id: `e${nextId}`, occurredAt: Date.now() };
+    if (rec.scope === 'refresh') {
+      refreshHistory.value = [rec, ...refreshHistory.value].slice(0, REFRESH_HISTORY);
+      unreadCount.value = Math.min(unreadCount.value + 1, REFRESH_HISTORY);
+    } else {
+      toasts.value = [...toasts.value, rec];
+    }
+    return rec;
+  }
+
+  /** Opening the history marks it read. The records STAY — read is not the
+   *  same as gone, and an operator who opens the panel twice should see the
+   *  same five. */
+  function markRead(): void {
+    unreadCount.value = 0;
+  }
+  function clearRefreshHistory(): void {
+    refreshHistory.value = [];
+    unreadCount.value = 0;
+  }
+  /**
+   * Say once, on screen, that a round the operator asked for did not work.
+   *
+   * Deliberately NOT one toast per failed participant: an outage fails every
+   * screen on the page, and the operator pressed one button. The detail is in
+   * the history, which this points at.
+   */
+  let announcedRound: number | null = null;
+  function announceRound(roundId: number): void {
+    if (announcedRound === roundId) return;
+    announcedRound = roundId;
+    nextId += 1;
+    toasts.value = [
+      ...toasts.value,
+      {
+        id: `e${nextId}`,
+        occurredAt: Date.now(),
+        scope: 'component',
+        owner: 'Refresh',
+        action: 'refreshing the page',
+        // One translated sentence rather than a summary plus a `detail`: the
+        // card renders `detail` verbatim, which is right for a server's words
+        // and wrong for ours — ours would have shown in English everywhere.
+        summary: 'Some of the page could not be refreshed — see the failures beside the refresh button.',
+        summaryKey: 'Some of the page could not be refreshed — see the failures beside the refresh button.',
+      },
+    ];
+  }
+
+  function dismissToast(id: string): void {
+    toasts.value = toasts.value.filter((t) => t.id !== id);
+  }
+  /** Signing out must not leave one person's failures on another's screen — a
+   *  record names what was read and what the server said about it. Nothing is
+   *  persisted, so clearing memory is the whole of it. */
+  function reset(): void {
+    refreshHistory.value = [];
+    toasts.value = [];
+    unreadCount.value = 0;
+  }
+
+  return {
+    refreshHistory,
+    unreadCount,
+    toasts,
+    hasUnread: computed(() => unreadCount.value > 0),
+    record,
+    announceRound,
+    markRead,
+    clearRefreshHistory,
+    dismissToast,
+    reset,
+  };
+});
+
+/**
+ * Turn whatever a failure arrived as into a record.
+ *
+ * Three shapes reach here and each knows a different amount: an `ApiError`
+ * carries the request and the server's answer, a plain `Error` carries a
+ * message, and anything else carries nothing worth trusting. The last case is
+ * why the summary is never `String(err)` alone — an object stringifies to
+ * "[object Object]", which tells an operator the request failed and nothing
+ * else.
+ */
+export function describeFailure(
+  err: unknown,
+  owner: string,
+  action: string,
+  scope: 'refresh' | 'component',
+): Omit<UiErrorRecord, 'id' | 'occurredAt'> {
+  const base = { scope, owner, action };
+  if (err instanceof BffApiError) {
+    return {
+      ...base,
+      method: err.method,
+      url: err.path ? redactUrl(err.path) : undefined,
+      status: err.status,
+      summary: describeApiError(err),
+      responseBody:
+        err.body === undefined || err.body === null ? undefined : redactBody(err.body),
+    };
+  }
+  if (err instanceof GraphUnavailableError) {
+    // Our sentence, not the server's — the BFF answered 200 and said it could
+    // not reach OAP, so there is no upstream message to quote.
+    return { ...base, summary: err.message, summaryKey: 'OAP could not be reached, so the graph was left as it was.' };
+  }
+  if (err instanceof Error) return { ...base, summary: err.message, detail: err.name };
+  return { ...base, summary: 'The request failed.', summaryKey: 'The request failed.' };
+}
+
+/**
+ * Report a query's failures to the refresh history — but ONLY the timer's.
+ *
+ * The distinction is the whole point. A failure while a round is out is the
+ * refresh's: nobody asked for it, nobody is necessarily watching, so it waits
+ * in the history. A failure outside a round is a screen loading for the first
+ * time, and the screen SAYS SO where the graph would have been — announcing it
+ * again in a history the operator has to open would be the same news twice.
+ *
+ * Deduplicated by message, because a backend that is down fails identically
+ * every cycle and five copies of one sentence is not five pieces of
+ * information.
+ */
+export function useRefreshErrorReport(opts: {
+  owner: MaybeRefOrGetter<string>;
+  action: MaybeRefOrGetter<string>;
+  error: Ref<Error | null> | { value: Error | null };
+}): void {
+  const center = useErrorCenterStore();
+  const auto = useAutoRefreshStore();
+  let lastReported: string | null = null;
+  watch(
+    () => opts.error.value,
+    (err) => {
+      if (!err) {
+        // Recovered. The next failure is news again even if it reads the same.
+        lastReported = null;
+        return;
+      }
+      // A cancellation is something the app DID — a capped round, a navigation.
+      // Reporting it would put our own decision in the operator's outage list.
+      if (isCancellation(err)) return;
+      const round = auto.currentRound;
+      if (!round) return;
+      const operatorAsked = OPERATOR_TRIGGERS.has(round.trigger);
+      // Repeat suppression is for the TIMER: an outage fails identically every
+      // cycle, and five copies of one sentence is not five pieces of
+      // information. It must not silence an operator's own retry — pressing
+      // Refresh during the same outage and being shown nothing is exactly the
+      // contradiction the toast exists to remove.
+      if (err.message === lastReported && !operatorAsked) return;
+      const repeat = err.message === lastReported;
+      lastReported = err.message;
+      if (repeat) {
+        // Already in the history from the round that first saw it; the
+        // operator still gets their answer.
+        center.announceRound(round.roundId);
+        return;
+      }
+      const written = center.record({
+        ...describeFailure(err, toValue(opts.owner), toValue(opts.action), 'refresh'),
+        trigger: round.trigger,
+        roundId: round.roundId,
+      });
+      // A round the OPERATOR set off gets an answer where they are looking, as
+      // well as its entry in the history. Clicking Refresh against a dead
+      // backend and being shown nothing is the contradiction this removes —
+      // they asked, so they are watching. Once per round, though: a page whose
+      // every widget failed would otherwise bury the screen in toasts saying
+      // the same thing.
+      if (written && operatorAsked) center.announceRound(round.roundId);
+    },
+    // SYNC so the round it belongs to is still the current one. The query
+    // settles its error inside the round and the round clears itself a
+    // microtask later; a deferred watcher would land somewhere between the
+    // two, and which side depends on how Vue's scheduler interleaves with the
+    // round's own continuation. Attribution should not rest on that ordering —
+    // a failure that lands on the wrong side is silently dropped, and nothing
+    // on screen would say so.
+    { flush: 'sync' },
+  );
+}
+
+/**
+ * Report a failure the operator caused. Shown at once, as a toast.
+ *
+ * Called from the action's own catch — never from the fetch layer, which
+ * cannot tell an operator's click from a background read and would announce
+ * both.
+ */
+export function reportActionFailure(err: unknown, owner: string, action: string): void {
+  if (isCancellation(err)) return;
+  useErrorCenterStore().record(describeFailure(err, owner, action, 'component'));
+}
+
+/** WE stopped it, so it is not news. */
+export function isCancellation(err: unknown): boolean {
+  if (err instanceof BffApiError) return err.cancelled === true;
+  return err instanceof Error && err.name === 'AbortError';
+}
+
+/**
+ * A round that gave up at its cap says so.
+ *
+ * Otherwise the cap is silent: the page keeps whatever landed before it, the
+ * countdown starts again, and nothing anywhere says a refresh was abandoned —
+ * so an operator reading a minute-old figure has no way to know it is one.
+ *
+ * Wired here rather than in the store because the store must not import this
+ * module: it is imported BY the time store, and reaching back would close a
+ * cycle at module-evaluation time.
+ */
+export function reportCappedRounds(): void {
+  const center = useErrorCenterStore();
+  useAutoRefreshStore().onRoundCapped((round) => {
+    center.record({
+      scope: 'refresh',
+      owner: 'Refresh',
+      action: 'refreshing the page',
+      summary: 'The refresh took too long and was given up on.',
+      summaryKey: 'The refresh took too long and was given up on.',
+      trigger: round.trigger,
+      roundId: round.roundId,
+    });
+    // If they asked for it, say so where they are looking — a refresh the
+    // operator pressed that quietly gave up is the same silence the history
+    // exists to break.
+    if (OPERATOR_TRIGGERS.has(round.trigger)) center.announceRound(round.roundId);
+  });
+}
+
+/**
+ * Signing out clears the failures.
+ *
+ * Registered once at module level, and it looks the store up at reset time
+ * rather than closing over an instance — the registry never unregisters, so a
+ * listener added per store instance would keep clearing whichever instance
+ * happened to be created first.
+ */
+onSessionReset(() => {
+  useErrorCenterStore().reset();
+});
diff --git a/apps/ui/src/controls/trafficStarvation.test.ts b/apps/ui/src/controls/trafficStarvation.test.ts
new file mode 100644
index 00000000..8d8217d0
--- /dev/null
+++ b/apps/ui/src/controls/trafficStarvation.test.ts
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Which traffic is allowed to hold the refresh timer.
+ *
+ * Loading time is not charged against the interval — that is the operator's
+ * rule, and it means the timer holds while the page is busy. But the shell's
+ * own pollers run on independent clocks by design, and letting them count made
+ * the coordinated timer restart every time one ticked: a thirty-second poller
+ * against a thirty-second cadence postponed every round for ever, so
+ * auto-refresh silently never happened at all. Nothing in the suite caught it,
+ * because nothing simulated a poller.
+ */
+
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { createPinia, setActivePinia } from 'pinia';
+
+interface FakeQuery { state: { fetchStatus: string }; meta?: { independentPoll?: boolean } }
+const queries: FakeQuery[] = [];
+let notify: () => void = () => {};
+vi.mock('@/api/queryClient', () => ({
+  queryClient: {
+    invalidateQueries: vi.fn(() => Promise.resolve()),
+    refetchQueries: vi.fn(() => Promise.resolve()),
+    isFetching: () => queries.filter((q) => q.state.fetchStatus === 'fetching').length,
+    getQueryCache: () => ({
+      subscribe: (fn: () => void) => { notify = fn; return () => { notify = () => {}; }; },
+      getAll: () => queries,
+    }),
+  },
+}));
+
+const { useAutoRefreshStore } = await import('./autoRefresh');
+
+/** Put a query into the cache and tell the store the cache moved. */
+function fetching(meta?: { independentPoll?: boolean }): () => void {
+  const q: FakeQuery = { state: { fetchStatus: 'fetching' }, ...(meta ? { meta } : {}) };
+  queries.push(q);
+  notify();
+  return () => {
+    q.state.fetchStatus = 'idle';
+    notify();
+  };
+}
+
+beforeEach(() => {
+  setActivePinia(createPinia());
+  queries.length = 0;
+});
+
+describe('independent pollers must not hold the timer', () => {
+  it('keeps counting down while a shell poller is in flight', () => {
+    const a = useAutoRefreshStore();
+    const before = a.secondsUntilNext;
+    expect(before, 'nothing was armed — the rest would prove nothing').not.toBeNull();
+
+    const settle = fetching({ independentPoll: true });
+
+    expect(
+      a.secondsUntilNext,
+      'a shell poller stopped the countdown, so its clock would postpone every round',
+    ).not.toBeNull();
+    settle();
+  });
+
+  it('holds while the PAGE is loading, and starts a full interval once it lands', () => {
+    const a = useAutoRefreshStore();
+    a.setInterval(30);
+
+    const settle = fetching();
+    expect(a.secondsUntilNext, 'the countdown ran while the page was loading').toBeNull();
+
+    settle();
+    expect(a.secondsUntilNext).toBeLessThanOrEqual(30);
+    expect(a.secondsUntilNext, 'the interval did not start afresh once loading finished').toBeGreaterThan(27);
+  });
+
+  it('a poller alongside page traffic does not extend the hold past the page', () => {
+    const a = useAutoRefreshStore();
+    const poller = fetching({ independentPoll: true });
+    const page = fetching();
+    expect(a.secondsUntilNext).toBeNull();
+
+    page();
+
+    expect(a.secondsUntilNext, 'the poller kept the timer held after the page had landed').not.toBeNull();
+    poller();
+  });
+});
diff --git a/apps/ui/src/controls/useAutoRefreshSubscribe.ts b/apps/ui/src/controls/useAutoRefreshSubscribe.ts
index 6c7aec97..6f0a19b1 100644
--- a/apps/ui/src/controls/useAutoRefreshSubscribe.ts
+++ b/apps/ui/src/controls/useAutoRefreshSubscribe.ts
@@ -15,22 +15,82 @@
  * limitations under the License.
  */
 
-import { watch } from 'vue';
+import { onScopeDispose, toValue } from 'vue';
+import type { MaybeRefOrGetter } from 'vue';
+import { useQueryClient } from '@tanstack/vue-query';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
 
 /**
  * Subscribe a refetch callback to the global auto-refresh ticker.
- * Composables (or views) call this with their query's `refetch` to
- * pick up manual refreshes + interval-driven ticks from the topbar.
- * The watch is anchored to the calling scope, so when the component
- * unmounts the subscription is torn down automatically.
+ *
+ * Composables (or views) call this with their query's `refetch` to pick up
+ * manual refreshes and interval-driven rounds from the topbar. Registration
+ * only — the store CALLS the subscriber, so there is no watcher here — and it
+ * is anchored to the calling scope, so unmounting tears it down.
+ *
+ * **Return the promise.** The ticker counts its next interval from when the
+ * ROUND FINISHES, not from when it started, and a callback that returns
+ * nothing is indistinguishable from one that finished instantly — so a
+ * subscriber that swallows its promise makes the countdown describe a round
+ * that is still running. `void refetch()` inside the callback is the easy way
+ * to get this wrong.
+ *
+ * **Refetch with `{ cancelRefetch: false }`.** A round must produce ONE request
+ * per logical query, and the default cancels whatever is already in flight and
+ * starts again — so two components sharing a query, or a key that has just
+ * moved (flipping the cold stage does exactly that), issued the request twice
+ * and threw the first away. Joining the fetch already out gives one request and
+ * one answer, which both observers then share.
  */
-export function useAutoRefreshSubscribe(refetch: () => Promise<unknown> | unknown): void {
+export function useAutoRefreshSubscribe(
+  refetch: () => Promise<unknown> | unknown,
+  /**
+   * The COMPONENT half of the control, beside the operator's global switch.
+   *
+   * A subscriber that is not ready — no service picked yet, a replay block, a
+   * block that froze its own window — is skipped for the round entirely: not
+   * called, and not awaited. Pass the query's OWN `enabled` ref rather than
+   * re-deriving the condition, because `refetch()` bypasses `enabled` and a
+   * second copy of the rule will eventually disagree with the first and fetch
+   * where the query itself would not.
+   */
+  enabled?: MaybeRefOrGetter<boolean>,
+  /**
+   * The query this subscription refetches, so a capped round can CANCEL it.
+   *
+   * Without a key the round can only stop waiting; with one it cancels the
+   * query, and a query function that took the signal it was handed aborts the
+   * request itself. Optional because a subscriber that cannot be cancelled is
+   * still better joined to the round than left on a timer of its own.
+   */
+  queryKey?: MaybeRefOrGetter<readonly unknown[] | readonly (readonly unknown[])[]>,
+): void {
   const auto = useAutoRefreshStore();
-  watch(
-    () => auto.tickCount,
-    () => {
-      void refetch();
-    },
+  const queryClient = queryKey === undefined ? null : useQueryClient();
+  // Registration ONLY. The store runs the round and awaits what it collects —
+  // also watching `tickCount` here would fetch twice per round.
+  onScopeDispose(
+    auto.joinRound((signal: AbortSignal) => {
+      if (enabled !== undefined && !toValue(enabled)) return undefined;
+      if (queryClient !== null) {
+        // One key, or several — a fan-out subscriber (the dashboard's compare
+        // cohort, the overview's per-layer groups) is one participant holding
+        // many queries, and cancelling only the first would leave the rest
+        // running past the cap the round is enforcing.
+        const named = toValue(queryKey!);
+        const keys: readonly (readonly unknown[])[] =
+          named.length > 0 && Array.isArray(named[0])
+            ? (named as readonly (readonly unknown[])[])
+            : [named as readonly unknown[]];
+        signal.addEventListener(
+          'abort',
+          () => {
+            for (const key of keys) void queryClient.cancelQueries({ queryKey: key, exact: true });
+          },
+          { once: true },
+        );
+      }
+      return refetch();
+    }),
   );
 }
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index 7278d9b7..b38d30e9 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -143,6 +143,73 @@ function refreshAll(): void {
   void refetchInfo();
   void refetchPreflight();
 }
+
+/**
+ * The template store's own state, as distinct from the `ui-management` module
+ * row above. That row says whether the ENDPOINT answers; this says what came
+ * back from it, when, and what the last failure said — three questions an
+ * operator asks in that order, and which "unreachable" alone answers none of.
+ */
+const store = computed(() => preflight.value?.templateStore ?? null);
+const storeBadgeState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
+  const s = store.value;
+  if (!s) return 'unknown';
+  if (s.mode === 'readonly') return 'ok';
+  // Unreachable but still rendering is a warning; unreachable with nothing to
+  // render is an outage. Collapsing the two is what made a blip look fatal.
+  if (s.servingRetained) return 'warn';
+  if (s.unreachable) return 'err';
+  return 'ok';
+});
+const storeBadgeLabel = computed<string>(() => {
+  const s = store.value;
+  if (!s) return t('unknown');
+  if (s.mode === 'readonly') return t('readonly · bundled');
+  if (s.servingRetained) return t('stale · store unreachable');
+  if (s.unreachable) return t('unreachable');
+  return t('loaded');
+});
+/** The clock time of the last read — `07:43:33`, not the full date. The date
+ *  is only ever today's on a page an operator is watching. */
+const storeLastSyncClock = computed<string>(() => {
+  const at = store.value?.lastSuccessfulSyncAt ?? null;
+  return at === null ? '' : new Date(at).toLocaleTimeString();
+});
+/** "4m ago" — the figure an operator scans for; the exact time sits underneath
+ *  it as the card's label. */
+/** The figures, as one list — so the card reads as a single answer rather than
+ *  as several unrelated ones. Zero is worth showing; a kind nobody publishes
+ *  is not, so translations only appear when there are some. */
+const storeCounts = computed<Array<{ value: number; label: string }>>(() => {
+  const s = store.value;
+  if (!s) return [];
+  // Pluralised the way the rest of the app does it — "1 alert pages" is the
+  // kind of thing that makes a status page look unfinished.
+  const label = (key: string, n: number): string => t(key, n, { named: { n } });
+  const out = [
+    { value: s.counts.layer ?? 0, label: label('layer template | layer templates', s.counts.layer ?? 0) },
+    { value: s.counts.overview ?? 0, label: label('overview | overviews', s.counts.overview ?? 0) },
+    { value: s.counts.alert ?? 0, label: label('alert page | alert pages', s.counts.alert ?? 0) },
+  ];
+  if (s.translations > 0) {
+    out.push({
+      value: s.translations,
+      label: label('translation overlay | translation overlays', s.translations),
+    });
+  }
+  return out;
+});
+const storeLastSyncShort = computed<string>(() => {
+  const at = store.value?.lastSuccessfulSyncAt ?? null;
+  if (at === null) return t('never');
+  // Off the page's own ticker, so it ages on screen rather than freezing at
+  // whatever it said when the pane rendered.
+  const sec = Math.max(1, Math.floor((now.value - at) / 1000));
+  if (sec < 60) return t('{n}s ago', { n: sec });
+  const min = Math.floor(sec / 60);
+  if (min < 60) return t('{n}m ago', { n: min });
+  return t('{n}h ago', { n: Math.floor(min / 60) });
+});
 </script>
 
 <template>
@@ -264,6 +331,54 @@ function refreshAll(): void {
       </table>
     </section>
 
+
+    <!-- ── Pane B2 · Dashboard templates ─────────────────────────── -->
+    <section v-if="store" class="pane">
+      <header class="pane-head">
+        <h2>{{ t('Dashboard templates') }}</h2>
+        <span class="sw-badge" :class="`is-${storeBadgeState}`">
+          <span class="state-dot" />{{ storeBadgeLabel }}
+        </span>
+      </header>
+
+      <p class="pane-lede">
+        {{ t('What Horizon has loaded from OAP\'s template store, and when. The module row above says whether the endpoint answers; this says what came back from it.') }}
+      </p>
+
+      <!-- ONE card, figures inline. Four cards each carrying a single number
+           gave a count the same weight as a whole pane, and read as four
+           unrelated facts rather than one answer to "what is loaded". -->
+      <div class="sw-card ts-card">
+        <div class="ts-counts">
+          <div v-for="c in storeCounts" :key="c.label" class="ts-count">
+            <span class="ts-num mono">{{ c.value }}</span>
+            <span class="ts-label">{{ c.label }}</span>
+          </div>
+        </div>
+        <!-- Relative figure plus the clock time it happened at. No countdown
+             to the next read: that is the browser's own timer, and rendering
+             it live would mean re-rendering this page every second to age a
+             label nobody is waiting on. -->
+        <div class="ts-read">
+          {{ t('Loaded') }} <strong>{{ storeLastSyncShort }}</strong>
+          <span v-if="storeLastSyncClock" class="mono">· {{ storeLastSyncClock }}</span>
+        </div>
+      </div>
+
+      <div v-if="store.servingRetained" class="last-error">
+        <strong>{{ t('Serving an earlier read') }}</strong>
+        <span>{{ t('The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.') }}</span>
+      </div>
+      <div v-else-if="store.unreachable" class="last-error">
+        <strong>{{ t('Nothing loaded') }}</strong>
+        <span>{{ t('The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.') }}</span>
+      </div>
+      <div v-if="store.lastError" class="last-error">
+        <strong>{{ t('Last error') }}</strong>
+        <code>{{ store.lastError.message }}</code>
+      </div>
+    </section>
+
     <!-- ── Pane C · Zipkin / OTLP trace endpoint ─────────────────── -->
     <section class="pane">
       <header class="pane-head">
@@ -392,6 +507,44 @@ function refreshAll(): void {
   max-width: 720px;
 }
 
+.ts-card {
+  padding: 12px 14px;
+}
+.ts-counts {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px 26px;
+  align-items: baseline;
+}
+.ts-count {
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+}
+.ts-num {
+  font-size: var(--sw-fs-lg);
+  font-weight: var(--sw-fw-semibold);
+  color: var(--sw-fg-1);
+  font-variant-numeric: tabular-nums;
+}
+.ts-label {
+  font-size: var(--sw-fs-sm);
+  color: var(--sw-fg-3);
+}
+.ts-read {
+  margin-top: 10px;
+  padding-top: 8px;
+  border-top: 1px solid var(--sw-line);
+  font-size: var(--sw-fs-sm);
+  color: var(--sw-fg-3);
+  display: flex;
+  align-items: baseline;
+  gap: 6px;
+}
+.ts-read strong {
+  color: var(--sw-fg-1);
+}
+
 .grid {
   display: grid;
   grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
diff --git a/apps/ui/src/features/operate/ttl/TtlView.vue b/apps/ui/src/features/operate/ttl/TtlView.vue
index aa6bcca5..cb963a93 100644
--- a/apps/ui/src/features/operate/ttl/TtlView.vue
+++ b/apps/ui/src/features/operate/ttl/TtlView.vue
@@ -59,25 +59,40 @@ const coldStage = computed<TtlStageBreakdown | null>(() => stages.value?.cold ??
 interface Row {
   label: string;
   days: number | undefined;
+  /** The setting that governs this figure, verbatim, so the operator can go
+   *  and change it without first working out which knob reaches this class. */
+  source: string;
 }
 
-function recordsRows(s: TtlStageBreakdown): Row[] {
-  return [
-    { label: t('Normal'),            days: s.records.normal },
-    { label: t('Trace'),             days: s.records.trace },
-    { label: t('Zipkin trace'),      days: s.records.zipkinTrace },
-    { label: t('Log'),               days: s.records.log },
-    { label: t('Browser error log'), days: s.records.browserErrorLog },
-  ];
-}
-function metricsRows(s: TtlStageBreakdown): Row[] {
+const CORE_RECORD_TTL = 'core.recordDataTTL';
+const CORE_METRICS_TTL = 'core.metricsDataTTL';
+
+/* What a NON-BanyanDB backend actually has, named the way an operator asks for
+ * it. BanyanDB is the only storage plugin implementing OAP's TTL query, reading
+ * a retention per group; ElasticSearch and JDBC both register the same default
+ * implementation, which repeats `core.recordDataTTL` across every record class
+ * and `core.metricsDataTTL` across every metric one.
+ *
+ * So the wire's nine numbers are two, and reproducing its shape here was noise
+ * three ways over: a Records / Metrics split that mirrors OAP's internals rather
+ * than any decision an operator makes, a Minute / Hour / Day trio that cannot
+ * differ, and a "Normal" class named after the wire field instead of its
+ * contents. The `records` group is alarms, events, sampled traces, top-N and the
+ * profiling records together, so `Others` is the honest name for it — singling
+ * out alarms would imply the rest are kept some other length.
+ *
+ * Ordered longest-lived first, which on the defaults is also metadata → metrics
+ * → records, so the list reads as a retention gradient rather than a wire dump. */
+function flatRows(s: TtlStageBreakdown): Row[] {
   return [
-    ...(s.metrics.metadata !== undefined ? [{ label: t('Metadata'), days: s.metrics.metadata }] : []),
-    { label: t('Minute'), days: s.metrics.minute },
-    { label: t('Hour'),   days: s.metrics.hour },
-    { label: t('Day'),    days: s.metrics.day },
+    { label: t('Metadata'), days: s.metrics.metadata ?? s.metrics.minute, source: CORE_METRICS_TTL },
+    { label: t('Metrics'),  days: s.metrics.minute, source: CORE_METRICS_TTL },
+    { label: t('Logs'),     days: s.records.log,    source: CORE_RECORD_TTL },
+    { label: t('Traces'),   days: s.records.trace,  source: CORE_RECORD_TTL },
+    { label: t('Others'),   days: s.records.normal, source: CORE_RECORD_TTL },
   ];
 }
+
 function fmtDays(n: number | undefined): string {
   if (n === undefined) return '—';
   if (n < 0) return '—';
@@ -129,6 +144,9 @@ const backendPillLabel = computed(() => {
  *  surfaces this so the operator isn't misled. */
 interface LifecycleRow {
   label: string;
+  /** The `bydb.yml` group this class is read from, shown only where the label
+   *  does not already give it away. */
+  source: string;
   hotDays: number;
   /** 0 when this class has no cold stage (any non-BanyanDB backend,
    *  or BanyanDB without a cold lifecycle stage configured). */
@@ -142,11 +160,20 @@ const lifecycleRecords = computed<LifecycleRow[]>(() => {
   const c = coldStage.value;
   const coldOr0 = (v: number | undefined): number => (v != null && v >= 0 ? v : 0);
   const rows: LifecycleRow[] = [
-    { label: t('Normal'),          hotDays: h.records.normal,          coldDays: coldOr0(c?.records.normal),          totalDays: 0 },
-    { label: t('Trace'),           hotDays: h.records.trace,           coldDays: coldOr0(c?.records.trace),           totalDays: 0 },
-    { label: t('Zipkin trace'),    hotDays: h.records.zipkinTrace,     coldDays: coldOr0(c?.records.zipkinTrace),     totalDays: 0 },
-    { label: t('Log'),             hotDays: h.records.log,             coldDays: coldOr0(c?.records.log),             totalDays: 0 },
-    { label: t('Browser err log'), hotDays: h.records.browserErrorLog, coldDays: coldOr0(c?.records.browserErrorLog), totalDays: 0 },
+    // Same catalog, same order as the flat backends, so the page reads the same
+    // wherever you land — the only difference is that these are separate groups
+    // in `bydb.yml` and can genuinely be tuned apart.
+    //
+    // `source` carries the group name only where the label does not already
+    // give it away: printing `metadata` under "Metadata" nine times over is
+    // noise, while `records` under "Others" is the one mapping nobody can guess.
+    // The group holds alarms, events, sampled traces, top-N and the profiling
+    // records together, which is why it is not named after any one of them.
+    { label: t('Logs'),               source: '',        hotDays: h.records.log,             coldDays: coldOr0(c?.records.log),             totalDays: 0 },
+    { label: t('Traces'),             source: '',        hotDays: h.records.trace,           coldDays: coldOr0(c?.records.trace),           totalDays: 0 },
+    { label: t('Zipkin traces'),      source: '',        hotDays: h.records.zipkinTrace,     coldDays: coldOr0(c?.records.zipkinTrace),     totalDays: 0 },
+    { label: t('Browser error logs'), source: '',        hotDays: h.records.browserErrorLog, coldDays: coldOr0(c?.records.browserErrorLog), totalDays: 0 },
+    { label: t('Others'),             source: 'records', hotDays: h.records.normal,          coldDays: coldOr0(c?.records.normal),          totalDays: 0 },
   ];
   return rows
     .filter((r) => Number.isFinite(r.hotDays) && r.hotDays > 0)
@@ -164,54 +191,40 @@ const lifecycleMetrics = computed<LifecycleRow[]>(() => {
     // section avoids a tiny zero-cold row dropped between two larger
     // ones with cold.
     ...(h.metrics.metadata !== undefined
-      ? [{ label: t('Metadata'), hotDays: h.metrics.metadata, coldDays: 0, totalDays: h.metrics.metadata }]
+      ? [{ label: t('Metadata'), source: '', hotDays: h.metrics.metadata, coldDays: 0, totalDays: h.metrics.metadata }]
       : []),
-    { label: t('Minute metric'), hotDays: h.metrics.minute, coldDays: coldOr0(c?.metrics.minute), totalDays: 0 },
-    { label: t('Hour metric'),   hotDays: h.metrics.hour,   coldDays: coldOr0(c?.metrics.hour),   totalDays: 0 },
-    { label: t('Day metric'),    hotDays: h.metrics.day,    coldDays: coldOr0(c?.metrics.day),    totalDays: 0 },
+    // Metrics break down here and only here: BanyanDB keeps a group per
+    // granularity, so the three are genuinely tunable apart.
+    { label: t('Minute metrics'), source: '', hotDays: h.metrics.minute, coldDays: coldOr0(c?.metrics.minute), totalDays: 0 },
+    { label: t('Hour metrics'),   source: '', hotDays: h.metrics.hour,   coldDays: coldOr0(c?.metrics.hour),   totalDays: 0 },
+    { label: t('Day metrics'),    source: '', hotDays: h.metrics.day,    coldDays: coldOr0(c?.metrics.day),    totalDays: 0 },
   ];
   return rows
     .filter((r) => Number.isFinite(r.hotDays) && r.hotDays > 0)
     .map((r) => ({ ...r, totalDays: r.totalDays || r.hotDays + r.coldDays }));
 });
 
-/** Collapse a per-class list into one row labelled "All <category>"
- *  when every class shares the exact same (hotDays, coldDays) pair.
- *  Non-BanyanDB always falls into this branch — OAP's default impl
- *  returns uniform values per category — so the operator sees a
- *  single honest summary instead of N identical bars. BanyanDB
- *  deployments with uniform per-class config get the same treatment
- *  naturally. When TTLs diverge, returns the per-class rows
- *  unchanged. */
-function collapseUniform(rows: LifecycleRow[], categoryLabel: string): LifecycleRow[] {
-  if (rows.length <= 1) return rows;
-  const first = rows[0];
-  const allMatch = rows.every((r) => r.hotDays === first.hotDays && r.coldDays === first.coldDays);
-  if (!allMatch) return rows;
-  return [
-    {
-      label: t('All {category} ({n})', { category: categoryLabel, n: rows.length }),
-      hotDays: first.hotDays,
-      coldDays: first.coldDays,
-      totalDays: first.totalDays,
-    },
-  ];
-}
-const lifecycleRecordsForRender = computed<LifecycleRow[]>(() =>
-  collapseUniform(lifecycleRecords.value, t('records')),
-);
-const lifecycleMetricsForRender = computed<LifecycleRow[]>(() =>
-  collapseUniform(lifecycleMetrics.value, t('metrics')),
-);
-
-/** Anchor for proportional row widths — single max across BOTH
- *  records and metrics so the visual comparison stays honest across
- *  sections (a 60-d metadata row should still tower over a 38-d
+/**
+ * One ordered list, in the same catalogue and the same order the flat backends
+ * use, so the page reads the same wherever you land.
+ *
+ * Rows that happen to share a TTL are NOT merged. Equal values were once
+ * collapsed into a single "All records" bar, which reads as a fact about the
+ * backend when it is only a fact about today's config: these are separate
+ * groups in `bydb.yml` and can be tuned apart at any time, and a page that
+ * hides four of them until someone does is a page you cannot plan from.
+ */
+const lifecycleForRender = computed<LifecycleRow[]>(() => [
+  ...lifecycleMetrics.value,
+  ...lifecycleRecords.value,
+]);
+
+/** Anchor for proportional row widths — one max across every row so the visual
+ *  comparison stays honest (a 60-d metadata row should still tower over a 38-d
  *  trace row). At least 1 to avoid div-by-0 during initial load. */
-const lifecycleMaxTotal = computed<number>(() => {
-  const all = [...lifecycleRecordsForRender.value, ...lifecycleMetricsForRender.value];
-  return Math.max(1, ...all.map((r) => r.totalDays));
-});
+const lifecycleMaxTotal = computed<number>(() =>
+  Math.max(1, ...lifecycleForRender.value.map((r) => r.totalDays)),
+);
 
 </script>
 
@@ -290,24 +303,23 @@ const lifecycleMaxTotal = computed<number>(() => {
           <h2>{{ t('Retention') }}</h2>
           <span class="pane-sub">{{ t('read-only · change on the OAP side') }}</span>
         </header>
+        <!-- One flat grid: the Records / Metrics split is OAP's internal
+             taxonomy, not a choice the operator makes, and every card here
+             already names the setting that governs it. -->
         <div class="sub-pane">
-          <h3>{{ t('Records') }}</h3>
-          <div class="grid">
-            <div v-for="row in recordsRows(hot)" :key="`flat-r-${row.label}`" class="sw-card kpi">
-              <div class="sw-card-head"><h4>{{ row.label }}</h4></div>
-              <div class="kpi-body"><div class="kpi-value">{{ fmtDays(row.days) }}</div></div>
-            </div>
-          </div>
-        </div>
-        <div class="sub-pane">
-          <h3>{{ t('Metrics') }}</h3>
           <div class="grid">
-            <div v-for="row in metricsRows(hot)" :key="`flat-m-${row.label}`" class="sw-card kpi">
+            <div v-for="row in flatRows(hot)" :key="`flat-${row.label}`" class="sw-card kpi">
               <div class="sw-card-head"><h4>{{ row.label }}</h4></div>
-              <div class="kpi-body"><div class="kpi-value">{{ fmtDays(row.days) }}</div></div>
+              <div class="kpi-body">
+                <div class="kpi-value">{{ fmtDays(row.days) }}</div>
+                <div class="kpi-source">{{ t('set by') }} <code>{{ row.source }}</code></div>
+              </div>
             </div>
           </div>
         </div>
+        <p class="flat-note">
+          {{ t('ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.') }}
+        </p>
         <p class="flat-note">
           {{ t('ⓘ Property data is omitted (forever-retained, no TTL reported).') }}
         </p>
@@ -323,46 +335,23 @@ const lifecycleMaxTotal = computed<number>(() => {
       <!-- Per-data-class lifecycle. One bar per class so divergent
            TTLs are visible at a glance; widths are proportional to
            total retention across BOTH sections so a 60-d metadata
-           row visibly towers over a 38-d trace row. Records and
-           metrics are split into two sub-sections that mirror the
-           cards below. -->
+           row visibly towers over a 38-d trace row. One ordered list
+           in the same catalogue the flat backends use. -->
       <section
-        v-if="lifecycleRecordsForRender.length > 0 || lifecycleMetricsForRender.length > 0"
+        v-if="lifecycleForRender.length > 0"
         class="lifecycle"
       >
         <header class="lifecycle__head">
           <h2>{{ t('Data lifecycle') }}</h2>
-          <span class="lifecycle__sub">{{ t('Per-data-class · widths proportional to total retention') }}</span>
+          <span class="lifecycle__sub">{{ t('One bydb.yml group per row · widths proportional to total retention') }}</span>
         </header>
 
-        <div v-if="lifecycleRecordsForRender.length > 0" class="lifecycle__group">
-          <h3 class="lifecycle__group-h">{{ t('Records') }}</h3>
-          <div v-for="r in lifecycleRecordsForRender" :key="`lc-rec-${r.label}`" class="lc-row">
-            <div class="lc-row__label">{{ r.label }}</div>
-            <div
-              class="lc-row__bar"
-              :style="{ width: `${(r.totalDays / lifecycleMaxTotal) * 100}%` }"
-              :aria-label="r.coldDays > 0
-                ? t('Hot + Warm {hot} days, Cold {cold} days', { hot: r.hotDays, cold: r.coldDays })
-                : t('Hot + Warm {hot} days', { hot: r.hotDays })"
-            >
-              <div class="lc-seg lc-seg--hot" :style="{ flex: r.hotDays }">
-                <span class="lc-seg__name">{{ t('Hot + Warm') }}</span>
-                <span class="lc-seg__days">{{ t('{n} d', { n: r.hotDays }) }}</span>
-              </div>
-              <div v-if="r.coldDays > 0" class="lc-seg lc-seg--cold" :style="{ flex: r.coldDays }">
-                <span class="lc-seg__name">{{ t('Cold') }}</span>
-                <span class="lc-seg__days">{{ t('{n} d', { n: r.coldDays }) }}</span>
-              </div>
+        <div v-if="lifecycleForRender.length > 0" class="lifecycle__group">
+          <div v-for="r in lifecycleForRender" :key="`lc-${r.label}`" class="lc-row">
+            <div class="lc-row__label">
+              {{ r.label }}
+              <span v-if="r.source" class="lc-row__source">{{ r.source }}</span>
             </div>
-            <div class="lc-row__total mono">{{ t('{n} d total', { n: r.totalDays }) }}</div>
-          </div>
-        </div>
-
-        <div v-if="lifecycleMetricsForRender.length > 0" class="lifecycle__group">
-          <h3 class="lifecycle__group-h">{{ t('Metrics') }}</h3>
-          <div v-for="r in lifecycleMetricsForRender" :key="`lc-met-${r.label}`" class="lc-row">
-            <div class="lc-row__label">{{ r.label }}</div>
             <div
               class="lc-row__bar"
               :style="{ width: `${(r.totalDays / lifecycleMaxTotal) * 100}%` }"
@@ -681,6 +670,21 @@ const lifecycleMaxTotal = computed<number>(() => {
   grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
   gap: 10px;
 }
+.kpi-source {
+  margin-top: 4px;
+  font-size: 11px;
+  color: var(--sw-text-dim);
+}
+.kpi-source code {
+  font-family: var(--sw-font-mono);
+  color: var(--sw-text-muted);
+}
+.lc-row__source {
+  display: block;
+  font-family: var(--sw-font-mono);
+  font-size: 10px;
+  color: var(--sw-text-dim);
+}
 .kpi .sw-card-head {
   display: flex;
   align-items: center;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index c455c4a4..208a82b6 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -95,7 +95,9 @@
   "AI Assistant": "KI-Assistent",
   "AI Assistant & MCP": "KI-Assistent & MCP",
   "AI request failed ({status}).": "KI-Anfrage fehlgeschlagen ({status}).",
+  "alert page | alert pages": "Alarmseite | Alarmseiten",
   "An ACTIVE rule must be inactivated first.": "Eine ACTIVE Regel muss zuerst deaktiviert werden.",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "Eine Administratorin oder ein Administrator hat es abgeschaltet — unter Operate → Templates wieder aktivieren.",
   "API dependency": "API-Abhängigkeit",
   "API dependency chain": "API-Abhängigkeitskette",
   "API dependency config": "Konfiguration der API-Abhängigkeiten",
@@ -158,7 +160,6 @@
   "All traffic": "Gesamter Traffic",
   "All verbs": "Alle Verbs",
   "All versions": "Alle Versionen",
-  "All {category} ({n})": "Alle {category} ({n})",
   "Allow": "Erlauben",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "Wurde auch in einem anderen Tab fortgesetzt, daher wurde diese Fassung nicht gespeichert. Öffnen Sie sie, um die zu behaltende Fassung zu wählen.",
   "Always visible": "Immer sichtbar",
@@ -225,13 +226,41 @@
   "Back to Overview": "Zurück zur Übersicht",
   "Back to list": "Zurück zur Liste",
   "Back to presets": "Zurück zu Voreinstellungen",
+  "Could not load the dependency graph.": "Der Abhängigkeitsgraph konnte nicht geladen werden.",
+  "Could not load the deployment topology.": "Die Deployment-Topologie konnte nicht geladen werden.",
+  "Could not load the instance relationship.": "Die Instanzbeziehung konnte nicht geladen werden.",
+  "Could not load the topology.": "Die Topologie konnte nicht geladen werden.",
+  "Dashboard templates": "Dashboard-Templates",
+  "Details": "Details",
   "Discard unsaved changes to this rule?": "Nicht gespeicherte Änderungen an dieser Regel verwerfen?",
+  "Endpoint dependency": "Endpoint-Abhängigkeit",
+  "expanding an endpoint": "Endpoint wird aufgeklappt",
+  "Hide details": "Details ausblenden",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon stellt nur dar, was OAP vorhält, und zeigt daher nichts an, statt auf die mitgelieferten Templates zurückzufallen.",
+  "Instance relationship": "Instanzbeziehung",
+  "layer template | layer templates": "Layer-Template | Layer-Templates",
+  "loaded": "geladen",
+  "Loaded": "Geladen",
+  "Loading dependencies for “{target}”…": "Abhängigkeiten für „{target}“ werden geladen…",
+  "Loading deployment for “{target}”…": "Deployment-Topologie für „{target}“ wird geladen…",
+  "Loading relationship for “{target}”…": "Beziehung für „{target}“ wird geladen…",
+  "Loading topology for “{target}”…": "Topologie für „{target}“ wird geladen…",
+  "Nothing loaded": "Nichts geladen",
+  "OAP could not be reached, so the graph was left as it was.": "OAP war nicht erreichbar, daher blieb der Graph unverändert.",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP erfasst die von dieser Regel definierten Metrics nicht mehr; Dashboards und Alarmregeln, die sie referenzieren, bleiben leer.",
+  "overview | overviews": "Übersicht | Übersichten",
   "permanently delete": "dauerhaft löschen",
   "Delete rule": "Regel löschen",
   "Ranges over {h}h can be slow on a large deployment.": "Zeiträume über {h} h können in großen Deployments langsam sein.",
+  "reading the deployment graph": "Deployment-Graph wird gelesen",
+  "reading the endpoint dependency graph": "Endpoint-Abhängigkeitsgraph wird gelesen",
+  "reading the instance relationship graph": "Instanzbeziehungs-Graph wird gelesen",
+  "reading the service map": "Service-Map wird gelesen",
+  "Refresh failures": "Fehlgeschlagene Aktualisierungen",
+  "Refreshing": "Wird aktualisiert",
   "Remove source map?": "Source map entfernen?",
   "Requires the source-map:write permission.": "Erfordert die Berechtigung source-map:write.",
+  "Serving an earlier read": "Liefert einen früheren Lesevorgang",
   "Snaps to a group boundary — a group is one hour": "Rastet auf eine Gruppengrenze ein – eine Gruppe ist eine Stunde",
   "Backend": "Backend",
   "Backend unreachable.": "Backend nicht erreichbar.",
@@ -247,9 +276,7 @@
   "Browse them in": "Durchsuchen Sie sie im",
   "Browser": "Browser",
   "Browser Logs": "Browser-Logs",
-  "Browser err log": "Browser-Fehlerlog",
   "Browser error": "Browser-Fehler",
-  "Browser error log": "Browser-Fehlerlog",
   "Browser errors": "Browser-Fehler",
   "Browser local time · {tz}": "Lokale Browser-Zeit · {tz}",
   "Browser local: {tz}": "Browser-Lokal: {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "Pausiert",
   "Peer": "Peer",
   "Peer addr": "Peer-Adresse",
-  "Per-data-class · widths proportional to total retention": "Pro Datenklasse · Breiten proportional zur Gesamtaufbewahrung",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "Dashboard je Endpoint (Widget-Satz dashboards.endpoint).",
   "Per-instance dashboard (dashboards.instance widget set).": "Dashboard je Instanz (Widget-Satz dashboards.instance).",
   "Per-layer setup": "Pro-Ebene-Setup",
@@ -1543,6 +1569,7 @@
   "Slowest": "Langsamste",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Einige Metriken konnten nicht geladen werden ({failed} von {total} Batches fehlgeschlagen) — leere Werte sind möglicherweise nicht verfügbar, nicht null.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Einige Metriken konnten nicht geladen werden ({failed} von {total} Batches fehlgeschlagen) — einige Endpoints oder Verbindungen fehlen möglicherweise.",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "Ein Teil der Seite konnte nicht aktualisiert werden — siehe die Fehler neben der Aktualisieren-Schaltfläche.",
   "Some records were not written in this window:": "In diesem Zeitraum konnten einige Einträge nicht geschrieben werden:",
   "Source": "Quelle",
   "Source maps": "Source-Maps",
@@ -1564,6 +1591,7 @@
   "Stage local": "Lokal zwischenspeichern",
   "Staged {locale} locally.": "Entwurf {locale} lokal zwischengespeichert.",
   "Stale": "Veraltet",
+  "stale · store unreachable": "veraltet · Speicher nicht erreichbar",
   "Start": "Start",
   "Start a profiling task": "Profiling-Aufgabe starten",
   "Start a profiling task on a target": "Profiling-Aufgabe auf einem Ziel starten",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "Die einzige übergeordnete Schranke — eine Ebene, die diese JS-regex ausschließt, erscheint nicht auf der Karte (sie wird unten weiterhin unter „Nicht angeheftet“ mit der Markierung „filtered out“ angezeigt). Der Standard {dotStar} lässt alle zu.",
   "The page below is rendered read-only and will fail any save / debug action.": "Die untenstehende Seite wird schreibgeschützt dargestellt und schlägt bei jeder Speicher- / Debug-Aktion fehl.",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "Die Landeseiten pro Ebene, die Benutzer sehen, wenn sie eine Ebene in der Sidebar auswählen.",
+  "The refresh took too long and was given up on.": "Die Aktualisierung hat zu lange gedauert und wurde abgebrochen.",
+  "The request failed.": "Die Anfrage ist fehlgeschlagen.",
   "The selected service is not in this layer — pick another one to query.": "Der gewählte Service liegt nicht in dieser Ebene — wählen Sie einen anderen für die Abfrage.",
   "The service list accepts at most {n} columns.": "Die Serviceliste akzeptiert höchstens {n} Spalten.",
   "The sign-in could not be verified. Please try again.": "Die Anmeldung konnte nicht verifiziert werden. Bitte versuchen Sie es erneut.",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "Der Anmeldeanbieter hat keine E-Mail-Adresse übermittelt.",
   "The sign-in provider returned no authorization code.": "Der Anmeldeanbieter hat keinen Autorisierungscode zurückgegeben.",
   "The single catch-all: any layer no tier pins lands here.": "Die einzige Auffangstufe: jede Ebene, die an keine Stufe angeheftet ist, landet hier.",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "Der Speicher ist derzeit nicht lesbar, daher sind dies die zuletzt geladenen Templates. Es sind Ihre eigenen, nicht die mitgelieferten Standards — nur nicht mehr aktuell.",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "Der Speicher wurde noch nie erfolgreich gelesen, daher bleiben Dashboards, Übersichten und Karten leer, bis er antwortet.",
+  "The template store could not be read.": "Der Template-Speicher konnte nicht gelesen werden.",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "Die {coldPill}-Pille in der oberen Leiste schaltet das Lesen von {hotWarm} auf {cold} um — {note}, aktiviere sie also nur, wenn der betrachtete Zeitbereich älter als die Hot+Warm-Grenze ist.",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "Der Tab {tab} ist eine eingebaute Ansicht, die direkt die SkyWalking-nativen Query-Protocol-APIs nutzt. Filter und Zeitraum stellen Operatoren zur Laufzeit auf der Seite selbst ein; hier ist nichts zu konfigurieren.",
   "Theme": "Design",
   "Theme: {name} (your override) — click to change": "Theme: {name} (Ihre Überschreibung) — zum Ändern klicken",
   "Theme: {name} — click to change": "Theme: {name} — zum Ändern klicken",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "Die Vorlagen dieser Ebenen liefern eine Service-Map, daher werden ihre Würfel als Aufrufgraph dargestellt (und legen den Grundstein für die ebenenübergreifende Hierarchie). Schreibgeschützt — es ist eine Eigenschaft der Ebenen-Vorlage, keine 3D-map-Einstellung.",
+  "This layer’s template is disabled.": "Das Template dieser Layer ist deaktiviert.",
   "This OAP serves traces via": "Dieses OAP liefert Traces über",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "Diese OAP-Version unterstützt nur Stichwort- und Tag-Filter. Aktualisiere für Ebenen- und Entitätsfilter.",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "Für diese Regel gibt es keine mitgelieferte Version auf der Festplatte — beim Löschen geht die einzige Kopie verloren, und Horizon kann sie nicht wiederherstellen.",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "Trace-Feed fehlgeschlagen.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Traffic",
+  "translation overlay | translation overlays": "Übersetzungs-Overlay | Übersetzungs-Overlays",
   "Translations": "Übersetzungen",
   "Tree": "Baum",
   "Triage": "Triage",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "Auslöse-Ausdruck",
   "Triggered": "Ausgelöst",
   "Turn Cold off": "Cold ausschalten",
+  "Turn auto-refresh off": "Automatische Aktualisierung ausschalten",
+  "Turn auto-refresh on": "Automatische Aktualisierung einschalten",
   "Turn off and delete": "Deaktivieren und löschen",
   "Turn off {component}?": "{component} deaktivieren?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Zwei-Port-Ansicht des OAP-Backends, mit dem Horizon verbunden ist. Query / GraphQL (:12800) versorgt jede Observability-Seite; der Admin [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "SkyWalking selbst im Blick: Ist der OAP-Cluster gesund, wie sieht der interne Metrik-Katalog aus, wie lange werden Daten aufbewahrt, welche Konfiguration läuft gerade.",
   "Welcome to SkyWalking": "Willkommen bei SkyWalking",
   "What each role can do": "Was jede Rolle tun kann",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "Was Horizon aus OAPs Template-Speicher geladen hat, und wann. Die Modulzeile oben sagt, ob der Endpunkt antwortet; diese sagt, was zurückkam.",
   "What you can do": "Was du tun kannst",
   "When": "Zeitpunkt",
   "Where it runs": "Wo sie läuft",
@@ -2630,6 +2668,7 @@
   "{count} in window": "{count} im Fenster",
   "{count} layers": "{count} Ebenen",
   "{count} logs": "{count} Logs",
+  "{count} refresh failures": "{count} fehlgeschlagene Aktualisierungen",
   "{count} unpinned → fallback": "{count} nicht angeheftet → Fallback",
   "{dur} ms — {pct}% of slowest": "{dur} ms — {pct}% des langsamsten",
   "{d}d {h}h ago": "vor {d}d {h}h",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "Nutzungen",
   "Credentials": "Anmeldedaten",
-  "Rate": "Rate"
+  "Rate": "Rate",
+  "Browser error log": "Browser-Fehlerlog",
+  "set by": "festgelegt durch",
+  "Zipkin traces": "Zipkin-Traces",
+  "Browser error logs": "Browser-Fehlerlogs",
+  "Minute metrics": "Minuten-Metriken",
+  "Hour metrics": "Stunden-Metriken",
+  "Day metrics": "Tages-Metriken",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ Dies sind Einstellungen aus OAPs `application.yml`. Dieses Backend hält eine Aufbewahrungsdauer für alles, was ein Record enthält, und eine für alles, was eine Metrik enthält — die Werte oben sind echt, lassen sich aber nicht einzeln anpa [...]
+  "One bydb.yml group per row · widths proportional to total retention": "Eine bydb.yml-Gruppe je Zeile · Breite proportional zur Gesamtaufbewahrung"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index f4298a3d..372d2c0f 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -95,7 +95,9 @@
   "AI Assistant": "AI Assistant",
   "AI Assistant & MCP": "AI Assistant & MCP",
   "AI request failed ({status}).": "AI request failed ({status}).",
+  "alert page | alert pages": "alert page | alert pages",
   "An ACTIVE rule must be inactivated first.": "An ACTIVE rule must be inactivated first.",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "An administrator turned it off — re-enable it under Operate → Templates.",
   "API dependency": "API dependency",
   "API dependency chain": "API dependency chain",
   "API dependency config": "API dependency config",
@@ -158,7 +160,6 @@
   "All traffic": "All traffic",
   "All verbs": "All verbs",
   "All versions": "All versions",
-  "All {category} ({n})": "All {category} ({n})",
   "Allow": "Allow",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.",
   "Always visible": "Always visible",
@@ -225,13 +226,41 @@
   "Back to Overview": "Back to Overview",
   "Back to list": "Back to list",
   "Back to presets": "Back to presets",
+  "Could not load the dependency graph.": "Could not load the dependency graph.",
+  "Could not load the deployment topology.": "Could not load the deployment topology.",
+  "Could not load the instance relationship.": "Could not load the instance relationship.",
+  "Could not load the topology.": "Could not load the topology.",
+  "Dashboard templates": "Dashboard templates",
+  "Details": "Details",
   "Discard unsaved changes to this rule?": "Discard unsaved changes to this rule?",
+  "Endpoint dependency": "Endpoint dependency",
+  "expanding an endpoint": "expanding an endpoint",
+  "Hide details": "Hide details",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon renders what OAP holds, so it is serving nothing rather than defaults.",
+  "Instance relationship": "Instance relationship",
+  "layer template | layer templates": "layer template | layer templates",
+  "loaded": "loaded",
+  "Loaded": "Loaded",
+  "Loading dependencies for “{target}”…": "Loading dependencies for “{target}”…",
+  "Loading deployment for “{target}”…": "Loading deployment for “{target}”…",
+  "Loading relationship for “{target}”…": "Loading relationship for “{target}”…",
+  "Loading topology for “{target}”…": "Loading topology for “{target}”…",
+  "Nothing loaded": "Nothing loaded",
+  "OAP could not be reached, so the graph was left as it was.": "OAP could not be reached, so the graph was left as it was.",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.",
+  "overview | overviews": "overview | overviews",
   "permanently delete": "permanently delete",
   "Delete rule": "Delete rule",
   "Ranges over {h}h can be slow on a large deployment.": "Ranges over {h}h can be slow on a large deployment.",
+  "reading the deployment graph": "reading the deployment graph",
+  "reading the endpoint dependency graph": "reading the endpoint dependency graph",
+  "reading the instance relationship graph": "reading the instance relationship graph",
+  "reading the service map": "reading the service map",
+  "Refresh failures": "Refresh failures",
+  "Refreshing": "Refreshing",
   "Remove source map?": "Remove source map?",
   "Requires the source-map:write permission.": "Requires the source-map:write permission.",
+  "Serving an earlier read": "Serving an earlier read",
   "Snaps to a group boundary — a group is one hour": "Snaps to a group boundary — a group is one hour",
   "Backend": "Backend",
   "Backend unreachable.": "Backend unreachable.",
@@ -247,9 +276,7 @@
   "Browse them in": "Browse them in",
   "Browser": "Browser",
   "Browser Logs": "Browser Logs",
-  "Browser err log": "Browser err log",
   "Browser error": "Browser error",
-  "Browser error log": "Browser error log",
   "Browser errors": "Browser errors",
   "Browser local time · {tz}": "Browser local time · {tz}",
   "Browser local: {tz}": "Browser local: {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "Paused",
   "Peer": "Peer",
   "Peer addr": "Peer addr",
-  "Per-data-class · widths proportional to total retention": "Per-data-class · widths proportional to total retention",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "Per-endpoint dashboard (dashboards.endpoint widget set).",
   "Per-instance dashboard (dashboards.instance widget set).": "Per-instance dashboard (dashboards.instance widget set).",
   "Per-layer setup": "Per-layer setup",
@@ -1543,6 +1569,7 @@
   "Slowest": "Slowest",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "Some of the page could not be refreshed — see the failures beside the refresh button.",
   "Some records were not written in this window:": "Some records were not written in this window:",
   "Source": "Source",
   "Source maps": "Source maps",
@@ -1564,6 +1591,7 @@
   "Stage local": "Stage local",
   "Staged {locale} locally.": "Staged {locale} locally.",
   "Stale": "Stale",
+  "stale · store unreachable": "stale · store unreachable",
   "Start": "Start",
   "Start a profiling task": "Start a profiling task",
   "Start a profiling task on a target": "Start a profiling task on a target",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.",
   "The page below is rendered read-only and will fail any save / debug action.": "The page below is rendered read-only and will fail any save / debug action.",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "The per-layer landing pages users see when they pick a layer in the sidebar.",
+  "The refresh took too long and was given up on.": "The refresh took too long and was given up on.",
+  "The request failed.": "The request failed.",
   "The selected service is not in this layer — pick another one to query.": "The selected service is not in this layer — pick another one to query.",
   "The service list accepts at most {n} columns.": "The service list accepts at most {n} columns.",
   "The sign-in could not be verified. Please try again.": "The sign-in could not be verified. Please try again.",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "The sign-in provider did not supply an email address.",
   "The sign-in provider returned no authorization code.": "The sign-in provider returned no authorization code.",
   "The single catch-all: any layer no tier pins lands here.": "The single catch-all: any layer no tier pins lands here.",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.",
+  "The template store could not be read.": "The template store could not be read.",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.",
   "Theme": "Theme",
   "Theme: {name} (your override) — click to change": "Theme: {name} (your override) — click to change",
   "Theme: {name} — click to change": "Theme: {name} — click to change",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.",
+  "This layer’s template is disabled.": "This layer’s template is disabled.",
   "This OAP serves traces via": "This OAP serves traces via",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "Traces feed failed.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Traffic",
+  "translation overlay | translation overlays": "translation overlay | translation overlays",
   "Translations": "Translations",
   "Tree": "Tree",
   "Triage": "Triage",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "Trigger expression",
   "Triggered": "Triggered",
   "Turn Cold off": "Turn Cold off",
+  "Turn auto-refresh off": "Turn auto-refresh off",
+  "Turn auto-refresh on": "Turn auto-refresh on",
   "Turn off and delete": "Turn off and delete",
   "Turn off {component}?": "Turn off {component}?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:171 [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.",
   "Welcome to SkyWalking": "Welcome to SkyWalking",
   "What each role can do": "What each role can do",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.",
   "What you can do": "What you can do",
   "When": "When",
   "Where it runs": "Where it runs",
@@ -2630,6 +2668,7 @@
   "{count} in window": "{count} in window",
   "{count} layers": "{count} layers",
   "{count} logs": "{count} logs",
+  "{count} refresh failures": "{count} refresh failures",
   "{count} unpinned → fallback": "{count} unpinned → fallback",
   "{dur} ms — {pct}% of slowest": "{dur} ms — {pct}% of slowest",
   "{d}d {h}h ago": "{d}d {h}h ago",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "Uses",
   "Credentials": "Credentials",
-  "Rate": "Rate"
+  "Rate": "Rate",
+  "Browser error log": "Browser error log",
+  "set by": "set by",
+  "Zipkin traces": "Zipkin traces",
+  "Browser error logs": "Browser error logs",
+  "Minute metrics": "Minute metrics",
+  "Hour metrics": "Hour metrics",
+  "Day metrics": "Day metrics",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.",
+  "One bydb.yml group per row · widths proportional to total retention": "One bydb.yml group per row · widths proportional to total retention"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 83729f17..570b50fb 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -95,7 +95,9 @@
   "AI Assistant": "Asistente de IA",
   "AI Assistant & MCP": "Asistente de IA y MCP",
   "AI request failed ({status}).": "La solicitud de IA falló ({status}).",
+  "alert page | alert pages": "página de alertas | páginas de alertas",
   "An ACTIVE rule must be inactivated first.": "Una regla ACTIVE debe inactivarse primero.",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "Un administrador la desactivó: vuelve a habilitarla en Operate → Templates.",
   "API dependency": "Dependencia de API",
   "API dependency chain": "Cadena de dependencias de API",
   "API dependency config": "Configuración de dependencias de API",
@@ -158,7 +160,6 @@
   "All traffic": "Todo el tráfico",
   "All verbs": "Todos los verbs",
   "All versions": "Todas las versiones",
-  "All {category} ({n})": "Todas las {category} ({n})",
   "Allow": "Permitir",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "También continuó en otra pestaña, por lo que esta versión no se guardó. Ábrela para elegir qué versión conservar.",
   "Always visible": "Siempre visible",
@@ -225,13 +226,41 @@
   "Back to Overview": "Volver al resumen",
   "Back to list": "Volver a la lista",
   "Back to presets": "Volver a los presets",
+  "Could not load the dependency graph.": "No se pudo cargar el grafo de dependencias.",
+  "Could not load the deployment topology.": "No se pudo cargar la topología de despliegue.",
+  "Could not load the instance relationship.": "No se pudo cargar la relación entre instancias.",
+  "Could not load the topology.": "No se pudo cargar la topología.",
+  "Dashboard templates": "Plantillas de paneles",
+  "Details": "Detalles",
   "Discard unsaved changes to this rule?": "¿Descartar los cambios sin guardar de esta regla?",
+  "Endpoint dependency": "Dependencia de endpoints",
+  "expanding an endpoint": "expandiendo un endpoint",
+  "Hide details": "Ocultar detalles",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon muestra lo que guarda OAP, así que no ofrece nada en lugar de recurrir a las plantillas predeterminadas.",
+  "Instance relationship": "Relación entre instancias",
+  "layer template | layer templates": "plantilla de layer | plantillas de layer",
+  "loaded": "cargado",
+  "Loaded": "Cargado",
+  "Loading dependencies for “{target}”…": "Cargando las dependencias de «{target}»…",
+  "Loading deployment for “{target}”…": "Cargando la topología de despliegue de «{target}»…",
+  "Loading relationship for “{target}”…": "Cargando la relación de «{target}»…",
+  "Loading topology for “{target}”…": "Cargando la topología de «{target}»…",
+  "Nothing loaded": "No se ha cargado nada",
+  "OAP could not be reached, so the graph was left as it was.": "No se pudo contactar con OAP, por lo que el grafo se mantuvo como estaba.",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP deja de recopilar las metrics que define esta regla; los paneles y las reglas de alarma que las referencian quedan vacíos.",
+  "overview | overviews": "vista general | vistas generales",
   "permanently delete": "eliminar permanentemente",
   "Delete rule": "Eliminar regla",
   "Ranges over {h}h can be slow on a large deployment.": "Los rangos de más de {h} h pueden ser lentos en un despliegue grande.",
+  "reading the deployment graph": "leyendo el grafo de despliegue",
+  "reading the endpoint dependency graph": "leyendo el grafo de dependencia de endpoints",
+  "reading the instance relationship graph": "leyendo el grafo de relación entre instancias",
+  "reading the service map": "leyendo el mapa de servicios",
+  "Refresh failures": "Fallos de actualización",
+  "Refreshing": "Actualizando",
   "Remove source map?": "¿Quitar el source map?",
   "Requires the source-map:write permission.": "Requiere el permiso source-map:write.",
+  "Serving an earlier read": "Sirviendo una lectura anterior",
   "Snaps to a group boundary — a group is one hour": "Se ajusta al límite del grupo: cada grupo dura una hora",
   "Backend": "Backend",
   "Backend unreachable.": "Backend no accesible.",
@@ -247,9 +276,7 @@
   "Browse them in": "Explóralas en",
   "Browser": "Browser",
   "Browser Logs": "Logs del navegador",
-  "Browser err log": "Log de errores del navegador",
   "Browser error": "Error de Browser",
-  "Browser error log": "Log de errores del navegador",
   "Browser errors": "Errores de Browser",
   "Browser local time · {tz}": "Hora local del navegador · {tz}",
   "Browser local: {tz}": "Local del navegador: {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "En pausa",
   "Peer": "Peer",
   "Peer addr": "Dirección del peer",
-  "Per-data-class · widths proportional to total retention": "Por clase de datos · anchos proporcionales a la retención total",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "Panel por endpoint (conjunto de widgets dashboards.endpoint).",
   "Per-instance dashboard (dashboards.instance widget set).": "Panel por instancia (conjunto de widgets dashboards.instance).",
   "Per-layer setup": "Configuración por capa",
@@ -1543,6 +1569,7 @@
   "Slowest": "Más lentas",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "No se pudieron cargar algunas métricas ({failed} de {total} lotes fallaron) — los valores en blanco pueden no estar disponibles, no son cero.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "No se pudieron cargar algunas métricas ({failed} de {total} lotes fallaron) — algunos endpoints o enlaces pueden faltar.",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "Parte de la página no pudo actualizarse: consulta los fallos junto al botón de actualizar.",
   "Some records were not written in this window:": "Algunos registros no se pudieron escribir en este intervalo:",
   "Source": "Fuente",
   "Source maps": "Source maps",
@@ -1564,6 +1591,7 @@
   "Stage local": "Guardar localmente",
   "Staged {locale} locally.": "Borrador {locale} guardado localmente.",
   "Stale": "Obsoleto",
+  "stale · store unreachable": "obsoleto · almacén inaccesible",
   "Start": "Inicio",
   "Start a profiling task": "Iniciar una tarea de profiling",
   "Start a profiling task on a target": "Iniciar una tarea de profiling sobre un objetivo",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "El único filtro de nivel superior — una capa que este regex de JS excluye queda fuera del mapa (aún aparece más abajo en No fijadas, marcada como \"filtered out\"). El valor predeterminado {dotStar} admite todas.",
   "The page below is rendered read-only and will fail any save / debug action.": "La página de abajo se muestra en sólo lectura y fallará en cualquier acción de guardar o depurar.",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "Las páginas de inicio por capa que los usuarios ven cuando seleccionan una capa en el menú lateral.",
+  "The refresh took too long and was given up on.": "La actualización tardó demasiado y se abandonó.",
+  "The request failed.": "La solicitud falló.",
   "The selected service is not in this layer — pick another one to query.": "El servicio seleccionado no está en esta capa: elige otro para consultar.",
   "The service list accepts at most {n} columns.": "La lista de servicios admite como máximo {n} columnas.",
   "The sign-in could not be verified. Please try again.": "No se ha podido verificar el inicio de sesión. Inténtalo de nuevo.",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "El proveedor no ha facilitado ninguna dirección de correo.",
   "The sign-in provider returned no authorization code.": "El proveedor no ha devuelto ningún código de autorización.",
   "The single catch-all: any layer no tier pins lands here.": "El único nivel comodín: cualquier capa que ningún nivel fije termina aquí.",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "El almacén no se puede leer ahora mismo, así que estas son las plantillas que se cargaron por última vez. Son las tuyas, no las predeterminadas del paquete: solo están desactualizadas.",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "El almacén nunca se ha leído correctamente, así que los paneles, las vistas generales y los mapas permanecerán vacíos hasta que responda.",
+  "The template store could not be read.": "No se pudo leer el almacén de plantillas.",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "La píldora {coldPill} en la barra superior cambia la lectura de {hotWarm} a {cold} — {note}, así que actívala solo cuando el rango de tiempo que estás viendo sea más antiguo que el límite hot+warm.",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "La pestaña {tab} es una vista integrada que usa directamente las APIs del query-protocol nativo de SkyWalking. Los operadores configuran los filtros y el rango de tiempo desde la propia página en tiempo de ejecución; aquí no hay nada que conectar.",
   "Theme": "Tema",
   "Theme: {name} (your override) — click to change": "Tema: {name} (tu ajuste) — haz clic para cambiarlo",
   "Theme: {name} — click to change": "Tema: {name} — haz clic para cambiarlo",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "Las plantillas de estas capas proporcionan un service map, por lo que sus cubos se muestran como un call graph (y siembran la jerarquía entre capas). Solo lectura — es una propiedad de la plantilla de la capa, no un ajuste del 3D-map.",
+  "This layer’s template is disabled.": "La plantilla de esta layer está deshabilitada.",
   "This OAP serves traces via": "Este OAP sirve trazas a través de",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "Esta versión de OAP solo admite filtros de palabra clave + tag. Actualiza para obtener filtros de capa + entidad.",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "Esta regla no tiene una versión incluida en disco: al eliminarla se borra la única copia y Horizon no puede restaurarla.",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "Falló la obtención de trazas.",
   "Traces · Zipkin": "Trazas · Zipkin",
   "Traffic": "Tráfico",
+  "translation overlay | translation overlays": "superposición de traducción | superposiciones de traducción",
   "Translations": "Traducciones",
   "Tree": "Árbol",
   "Triage": "Triaje",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "Expresión del disparador",
   "Triggered": "Disparada",
   "Turn Cold off": "Desactivar Cold",
+  "Turn auto-refresh off": "Desactivar la actualización automática",
+  "Turn auto-refresh on": "Activar la actualización automática",
   "Turn off and delete": "Desactivar y eliminar",
   "Turn off {component}?": "¿Desactivar {component}?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Vista de dos puertos del backend OAP al que está conectado Horizon. Query / GraphQL (:12800) alimenta cada página de observabilidad; el  [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Vigilar el propio SkyWalking: si el clúster OAP está sano, qué aspecto tiene el catálogo interno de métricas, cuánto tiempo se conservan los datos y cuál es la configuración en ejecución.",
   "Welcome to SkyWalking": "Te damos la bienvenida a SkyWalking",
   "What each role can do": "Qué puede hacer cada rol",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "Qué ha cargado Horizon del almacén de plantillas de OAP, y cuándo. La fila del módulo de arriba indica si el endpoint responde; esta indica qué devolvió.",
   "What you can do": "Lo que puedes hacer",
   "When": "Cuándo",
   "Where it runs": "Dónde se ejecuta",
@@ -2630,6 +2668,7 @@
   "{count} in window": "{count} en la ventana",
   "{count} layers": "{count} capas",
   "{count} logs": "{count} logs",
+  "{count} refresh failures": "{count} fallos de actualización",
   "{count} unpinned → fallback": "{count} no fijadas → respaldo",
   "{dur} ms — {pct}% of slowest": "{dur} ms — {pct}% de las más lentas",
   "{d}d {h}h ago": "hace {d}d {h}h",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "Usos",
   "Credentials": "Credenciales",
-  "Rate": "Tasa"
+  "Rate": "Tasa",
+  "Browser error log": "Log de errores del navegador",
+  "set by": "definido por",
+  "Zipkin traces": "Trazas Zipkin",
+  "Browser error logs": "Logs de errores del navegador",
+  "Minute metrics": "Métricas por minuto",
+  "Hour metrics": "Métricas por hora",
+  "Day metrics": "Métricas por día",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ Son ajustes del `application.yml` de OAP. Este backend mantiene una retención para todo lo que es un record y otra para todo lo que es una métrica, así que los valores de arriba son reales pero no pueden ajustarse por separado. La retenci [...]
+  "One bydb.yml group per row · widths proportional to total retention": "Un grupo de bydb.yml por fila · anchos proporcionales a la retención total"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index e510f07b..6841040b 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -95,7 +95,9 @@
   "AI Assistant": "Assistant IA",
   "AI Assistant & MCP": "Assistant IA et MCP",
   "AI request failed ({status}).": "Échec de la requête IA ({status}).",
+  "alert page | alert pages": "page d’alerte | pages d’alerte",
   "An ACTIVE rule must be inactivated first.": "Une règle ACTIVE doit d’abord être désactivée.",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "Un administrateur l’a désactivé — réactivez-le dans Operate → Templates.",
   "API dependency": "Dépendance d'API",
   "API dependency chain": "Chaîne de dépendances d'API",
   "API dependency config": "Configuration des dépendances d'API",
@@ -158,7 +160,6 @@
   "All traffic": "Tout le trafic",
   "All verbs": "Tous les verbs",
   "All versions": "Toutes les versions",
-  "All {category} ({n})": "Tous les {category} ({n})",
   "Allow": "Autoriser",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "La conversation a aussi été poursuivie dans un autre onglet, donc cette version n'a pas été enregistrée. Ouvrez-la pour choisir la version à conserver.",
   "Always visible": "Toujours visible",
@@ -225,13 +226,41 @@
   "Back to Overview": "Retour à la vue d'ensemble",
   "Back to list": "Retour à la liste",
   "Back to presets": "Retour aux préréglages",
+  "Could not load the dependency graph.": "Impossible de charger le graphe de dépendances.",
+  "Could not load the deployment topology.": "Impossible de charger la topologie de déploiement.",
+  "Could not load the instance relationship.": "Impossible de charger la relation entre instances.",
+  "Could not load the topology.": "Impossible de charger la topologie.",
+  "Dashboard templates": "Modèles de tableaux de bord",
+  "Details": "Détails",
   "Discard unsaved changes to this rule?": "Abandonner les modifications non enregistrées de cette règle ?",
+  "Endpoint dependency": "Dépendance des endpoints",
+  "expanding an endpoint": "déploiement d’un endpoint",
+  "Hide details": "Masquer les détails",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon n’affiche que ce que contient OAP : il ne montre donc rien plutôt que de revenir aux templates fournis.",
+  "Instance relationship": "Relation entre instances",
+  "layer template | layer templates": "modèle de layer | modèles de layer",
+  "loaded": "chargé",
+  "Loaded": "Chargé",
+  "Loading dependencies for “{target}”…": "Chargement des dépendances de « {target} »…",
+  "Loading deployment for “{target}”…": "Chargement de la topologie de déploiement de « {target} »…",
+  "Loading relationship for “{target}”…": "Chargement de la relation de « {target} »…",
+  "Loading topology for “{target}”…": "Chargement de la topologie de « {target} »…",
+  "Nothing loaded": "Rien de chargé",
+  "OAP could not be reached, so the graph was left as it was.": "OAP étant injoignable, le graphe a été conservé tel quel.",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP cesse de collecter les metrics définies par cette règle ; les tableaux de bord et règles d’alarme qui les référencent se vident.",
+  "overview | overviews": "vue d’ensemble | vues d’ensemble",
   "permanently delete": "supprimer définitivement",
   "Delete rule": "Supprimer la règle",
   "Ranges over {h}h can be slow on a large deployment.": "Les plages de plus de {h} h peuvent être lentes sur un déploiement de grande taille.",
+  "reading the deployment graph": "lecture du graphe de déploiement",
+  "reading the endpoint dependency graph": "lecture du graphe de dépendance des endpoints",
+  "reading the instance relationship graph": "lecture du graphe de relation entre instances",
+  "reading the service map": "lecture de la carte des services",
+  "Refresh failures": "Échecs d’actualisation",
+  "Refreshing": "Actualisation",
   "Remove source map?": "Supprimer la source map ?",
   "Requires the source-map:write permission.": "Nécessite la permission source-map:write.",
+  "Serving an earlier read": "Affiche une lecture antérieure",
   "Snaps to a group boundary — a group is one hour": "S'aligne sur une limite de groupe : un groupe dure une heure",
   "Backend": "Backend",
   "Backend unreachable.": "Backend injoignable.",
@@ -247,9 +276,7 @@
   "Browse them in": "Parcourez-les dans",
   "Browser": "Browser",
   "Browser Logs": "Journaux navigateur",
-  "Browser err log": "Journal d'erreur du navigateur",
   "Browser error": "Erreur Browser",
-  "Browser error log": "Journal d'erreur du navigateur",
   "Browser errors": "Erreurs Browser",
   "Browser local time · {tz}": "Heure locale du navigateur · {tz}",
   "Browser local: {tz}": "Local du navigateur : {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "En pause",
   "Peer": "Pair",
   "Peer addr": "Adresse du pair",
-  "Per-data-class · widths proportional to total retention": "Par classe de données · largeurs proportionnelles à la rétention totale",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "Tableau de bord par endpoint (jeu de widgets dashboards.endpoint).",
   "Per-instance dashboard (dashboards.instance widget set).": "Tableau de bord par instance (jeu de widgets dashboards.instance).",
   "Per-layer setup": "Configuration par couche",
@@ -1543,6 +1569,7 @@
   "Slowest": "Plus lents",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Certaines métriques n'ont pas pu être chargées ({failed} lots sur {total} en échec) — les valeurs vides peuvent être indisponibles, et non nulles.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Certaines métriques n'ont pas pu être chargées ({failed} lots sur {total} en échec) — certains endpoints ou liens peuvent être manquants.",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "Une partie de la page n’a pas pu être actualisée — voir les échecs à côté du bouton d’actualisation.",
   "Some records were not written in this window:": "Certaines entrées n'ont pas pu être écrites sur cette plage :",
   "Source": "Source",
   "Source maps": "Source maps",
@@ -1564,6 +1591,7 @@
   "Stage local": "Enregistrer en local",
   "Staged {locale} locally.": "Brouillon {locale} enregistré en local.",
   "Stale": "Obsolète",
+  "stale · store unreachable": "obsolète · magasin injoignable",
   "Start": "Début",
   "Start a profiling task": "Démarrer une tâche de profilage",
   "Start a profiling task on a target": "Démarrer une tâche de profilage sur une cible",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "L'unique filtre de premier niveau — une couche que ce regex JS exclut disparaît de la carte (elle reste affichée plus bas dans les Non épinglées, marquée « filtré »). La valeur par défaut {dotStar} admet tout.",
   "The page below is rendered read-only and will fail any save / debug action.": "La page ci-dessous est affichée en lecture seule et toute action d'enregistrement ou de débogage échouera.",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "Les pages d'accueil par couche que les utilisateurs voient lorsqu'ils choisissent une couche dans la barre latérale.",
+  "The refresh took too long and was given up on.": "L’actualisation a pris trop de temps et a été abandonnée.",
+  "The request failed.": "La requête a échoué.",
   "The selected service is not in this layer — pick another one to query.": "Le service sélectionné n'est pas dans cette couche — choisissez-en un autre pour interroger.",
   "The service list accepts at most {n} columns.": "La liste des services accepte au maximum {n} colonnes.",
   "The sign-in could not be verified. Please try again.": "La connexion n’a pas pu être vérifiée. Veuillez réessayer.",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "Le fournisseur n’a fourni aucune adresse e-mail.",
   "The sign-in provider returned no authorization code.": "Le fournisseur n’a renvoyé aucun code d’autorisation.",
   "The single catch-all: any layer no tier pins lands here.": "Le palier fourre-tout unique : toute couche qu'aucun palier n'épingle atterrit ici.",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "Le magasin est actuellement illisible : voici donc les modèles chargés en dernier. Ce sont les vôtres, pas les valeurs par défaut fournies — simplement plus à jour.",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "Le magasin n’a jamais été lu avec succès : les tableaux de bord, vues d’ensemble et cartes resteront vides jusqu’à ce qu’il réponde.",
+  "The template store could not be read.": "Le magasin de templates n’a pas pu être lu.",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "La puce {coldPill} de la barre supérieure bascule la lecture de {hotWarm} vers {cold} — {note}, ne l'activez donc que lorsque la plage temporelle que vous consultez est plus ancienne que la limite chaud+tiède.",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "L'onglet {tab} est une vue intégrée qui utilise directement les API natives du query-protocol SkyWalking. Les opérateurs règlent les filtres et la plage de temps à l'exécution depuis la page elle-même ; il n'y a rien à configurer ici.",
   "Theme": "Thème",
   "Theme: {name} (your override) — click to change": "Thème : {name} (votre préférence) — cliquez pour changer",
   "Theme: {name} — click to change": "Thème : {name} — cliquez pour changer",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "Les modèles de ces couches fournissent une carte de services, leurs cubes s'affichent donc comme un graphe d'appels (et amorcent la hiérarchie inter-couches). Lecture seule — c'est une propriété du modèle de couche, pas un paramètre de la 3D-map.",
+  "This layer’s template is disabled.": "Le template de cette layer est désactivé.",
   "This OAP serves traces via": "Cet OAP sert les traces via",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "Cette version d'OAP ne prend en charge que les filtres mot-clé + tag. Mettez à niveau pour les filtres couche + entité.",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "Cette règle n’a pas de version fournie sur le disque : la supprimer efface l’unique copie, et Horizon ne peut pas la restaurer.",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "Échec du flux de traces.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Trafic",
+  "translation overlay | translation overlays": "surcouche de traduction | surcouches de traduction",
   "Translations": "Traductions",
   "Tree": "Arbre",
   "Triage": "Triage",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "Expression de déclenchement",
   "Triggered": "Déclenchée",
   "Turn Cold off": "Désactiver Cold",
+  "Turn auto-refresh off": "Désactiver l’actualisation automatique",
+  "Turn auto-refresh on": "Activer l’actualisation automatique",
   "Turn off and delete": "Désactiver et supprimer",
   "Turn off {component}?": "Désactiver {component} ?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Vue à deux ports du backend OAP auquel Horizon est connecté. Query / GraphQL (:12800) alimente chaque page d'observabilité ; l'hôte d'ad [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Surveiller SkyWalking lui-même : le cluster OAP est-il en bonne santé, à quoi ressemble le catalogue de métriques interne, combien de temps les données sont-elles conservées, quelle configuration tourne actuellement.",
   "Welcome to SkyWalking": "Bienvenue dans SkyWalking",
   "What each role can do": "Ce que chaque rôle peut faire",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "Ce que Horizon a chargé depuis le magasin de modèles d’OAP, et quand. La ligne du module ci-dessus indique si l’endpoint répond ; celle-ci indique ce qui en est revenu.",
   "What you can do": "Ce que vous pouvez faire",
   "When": "Quand",
   "Where it runs": "Où elle s’exécute",
@@ -2630,6 +2668,7 @@
   "{count} in window": "{count} dans la fenêtre",
   "{count} layers": "{count} couches",
   "{count} logs": "{count} logs",
+  "{count} refresh failures": "{count} échecs d’actualisation",
   "{count} unpinned → fallback": "{count} non épinglées → repli",
   "{dur} ms — {pct}% of slowest": "{dur} ms — {pct}% du plus lent",
   "{d}d {h}h ago": "il y a {d}j {h}h",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "Utilisations",
   "Credentials": "Identifiants",
-  "Rate": "Taux"
+  "Rate": "Taux",
+  "Browser error log": "Journal d’erreurs navigateur",
+  "set by": "défini par",
+  "Zipkin traces": "Traces Zipkin",
+  "Browser error logs": "Journaux d’erreurs navigateur",
+  "Minute metrics": "Métriques à la minute",
+  "Hour metrics": "Métriques à l’heure",
+  "Day metrics": "Métriques au jour",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ Ce sont des réglages du `application.yml` d'OAP. Ce backend conserve une seule rétention pour tout ce qui relève des records et une autre pour tout ce qui relève des métriques : les valeurs ci-dessus sont réelles mais ne peuvent pas être  [...]
+  "One bydb.yml group per row · widths proportional to total retention": "Un groupe bydb.yml par ligne · largeurs proportionnelles à la rétention totale"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index dc52c07c..c5eb9c2d 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -95,7 +95,9 @@
   "AI Assistant": "AI アシスタント",
   "AI Assistant & MCP": "AI アシスタントと MCP",
   "AI request failed ({status}).": "AIリクエストが失敗しました({status})。",
+  "alert page | alert pages": "アラートページ",
   "An ACTIVE rule must be inactivated first.": "ACTIVE なルールは先に無効化する必要があります。",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "管理者が無効にしました。Operate → Templates で再度有効にできます。",
   "API dependency": "API 依存関係",
   "API dependency chain": "API 依存チェーン",
   "API dependency config": "API 依存関係の設定",
@@ -158,7 +160,6 @@
   "All traffic": "すべてのトラフィック",
   "All verbs": "すべての verb",
   "All versions": "すべてのバージョン",
-  "All {category} ({n})": "すべての {category}({n})",
   "Allow": "許可する",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "別のタブでも続行されたため、このバージョンは保存されていません。開いて、どちらのバージョンを残すか選択してください。",
   "Always visible": "常に表示",
@@ -225,13 +226,41 @@
   "Back to Overview": "概要に戻る",
   "Back to list": "一覧に戻る",
   "Back to presets": "プリセットに戻る",
+  "Could not load the dependency graph.": "依存関係グラフを読み込めませんでした。",
+  "Could not load the deployment topology.": "デプロイトポロジーを読み込めませんでした。",
+  "Could not load the instance relationship.": "インスタンス関係を読み込めませんでした。",
+  "Could not load the topology.": "トポロジーを読み込めませんでした。",
+  "Dashboard templates": "ダッシュボードテンプレート",
+  "Details": "詳細",
   "Discard unsaved changes to this rule?": "このルールの未保存の変更を破棄しますか?",
+  "Endpoint dependency": "エンドポイント依存関係",
+  "expanding an endpoint": "エンドポイントの展開",
+  "Hide details": "詳細を隠す",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon は OAP が保持している内容のみを描画するため、既定のテンプレートに戻すことはせず、何も表示しません。",
+  "Instance relationship": "インスタンス関係",
+  "layer template | layer templates": "Layer テンプレート",
+  "loaded": "読み込み済み",
+  "Loaded": "読み込み",
+  "Loading dependencies for “{target}”…": "「{target}」の依存関係を読み込み中…",
+  "Loading deployment for “{target}”…": "「{target}」のデプロイトポロジーを読み込み中…",
+  "Loading relationship for “{target}”…": "「{target}」の関係を読み込み中…",
+  "Loading topology for “{target}”…": "「{target}」のトポロジーを読み込み中…",
+  "Nothing loaded": "何も読み込まれていません",
+  "OAP could not be reached, so the graph was left as it was.": "OAP に接続できなかったため、グラフはそのまま保持されています。",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP はこのルールが定義する metrics の収集を停止します。それらを参照するダッシュボードやアラームルールは空になります。",
+  "overview | overviews": "概要",
   "permanently delete": "完全に削除",
   "Delete rule": "ルールを削除",
   "Ranges over {h}h can be slow on a large deployment.": "{h} 時間を超える範囲は、大規模なデプロイでは遅くなることがあります。",
+  "reading the deployment graph": "デプロイメントグラフの読み込み",
+  "reading the endpoint dependency graph": "エンドポイント依存関係グラフの読み込み",
+  "reading the instance relationship graph": "インスタンス関係グラフの読み込み",
+  "reading the service map": "サービスマップの読み込み",
+  "Refresh failures": "更新の失敗",
+  "Refreshing": "更新中",
   "Remove source map?": "source map を削除しますか?",
   "Requires the source-map:write permission.": "source-map:write 権限が必要です。",
+  "Serving an earlier read": "以前の読み込み結果を使用中",
   "Snaps to a group boundary — a group is one hour": "グループの境界に合わせます — 1 グループは 1 時間です",
   "Backend": "バックエンド",
   "Backend unreachable.": "バックエンドに到達できません。",
@@ -247,9 +276,7 @@
   "Browse them in": "次から参照できます:",
   "Browser": "Browser",
   "Browser Logs": "ブラウザログ",
-  "Browser err log": "ブラウザエラーログ",
   "Browser error": "Browser エラー",
-  "Browser error log": "ブラウザエラーログ",
   "Browser errors": "Browser エラー",
   "Browser local time · {tz}": "ブラウザのローカル時刻 · {tz}",
   "Browser local: {tz}": "ブラウザのローカル: {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "一時停止中",
   "Peer": "ピア",
   "Peer addr": "ピアアドレス",
-  "Per-data-class · widths proportional to total retention": "データクラスごと · 幅は合計保持期間に比例",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "エンドポイントごとのダッシュボード(dashboards.endpoint のウィジェットセット)。",
   "Per-instance dashboard (dashboards.instance widget set).": "インスタンスごとのダッシュボード(dashboards.instance のウィジェットセット)。",
   "Per-layer setup": "レイヤーごとの設定",
@@ -1543,6 +1569,7 @@
   "Slowest": "遅い順",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "一部のメトリクスを読み込めませんでした({total} バッチ中 {failed} バッチが失敗)。空白の値はゼロではなく、取得できなかった可能性があります。",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "一部のメトリクスを読み込めませんでした({total} バッチ中 {failed} バッチが失敗)。一部のエンドポイントやリンクが欠落している可能性があります。",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "ページの一部を更新できませんでした — 更新ボタン横の失敗記録を確認してください。",
   "Some records were not written in this window:": "この期間に書き込めなかった記録があります:",
   "Source": "ソース",
   "Source maps": "Source map",
@@ -1564,6 +1591,7 @@
   "Stage local": "ローカルに保存",
   "Staged {locale} locally.": "{locale} の下書きをローカルに保存しました。",
   "Stale": "不整合",
+  "stale · store unreachable": "古い状態 · ストアに接続できません",
   "Start": "開始",
   "Start a profiling task": "プロファイリングタスクを開始",
   "Start a profiling task on a target": "ターゲットでプロファイリングタスクを開始",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "唯一の最上位ゲートです — この JS regex が除外したレイヤーはマップから外れます(「filtered out」のタグ付きで下の未ピン留め欄には表示されます)。デフォルトの {dotStar} はすべてを許可します。",
   "The page below is rendered read-only and will fail any save / debug action.": "以下のページは読み取り専用で表示され、保存・デバッグ操作はすべて失敗します。",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "サイドバーでレイヤーを選択したときに表示される、レイヤーごとのランディングページ。",
+  "The refresh took too long and was given up on.": "更新に時間がかかりすぎたため中止しました。",
+  "The request failed.": "リクエストに失敗しました。",
   "The selected service is not in this layer — pick another one to query.": "選択中のサービスはこのレイヤーにありません — 別のサービスを選んでクエリしてください。",
   "The service list accepts at most {n} columns.": "サービス一覧の列は最大 {n} 件です。",
   "The sign-in could not be verified. Please try again.": "サインインを検証できませんでした。もう一度お試しください。",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "サインインプロバイダーからメールアドレスが提供されませんでした。",
   "The sign-in provider returned no authorization code.": "サインインプロバイダーから認可コードが返されませんでした。",
   "The single catch-all: any layer no tier pins lands here.": "唯一の受け皿です。どのティアにもピン留めされていないレイヤーはここに配置されます。",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "現在ストアを読み取れないため、最後に読み込めたテンプレートを表示しています。同梱の既定値ではなく利用者自身の設定であり、単に最新ではありません。",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "ストアの読み込みに一度も成功していないため、応答が返るまでダッシュボード・概要・トポロジーは空のままです。",
+  "The template store could not be read.": "テンプレートストアを読み取れませんでした。",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "トップバーの {coldPill} ピルは、読み取りを {hotWarm} から {cold} に切り替えます — {note}。表示中の時間範囲が hot+warm の境界より古い場合にのみ有効にしてください。",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "{tab} タブは、SkyWalking ネイティブの query-protocol API を直接利用する組み込みビューです。フィルタや時間範囲はページ上で実行時に設定するため、ここで設定する項目はありません。",
   "Theme": "テーマ",
   "Theme: {name} (your override) — click to change": "テーマ: {name}(自分の設定)— クリックで変更",
   "Theme: {name} — click to change": "テーマ: {name} — クリックで変更",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "これらのレイヤーのテンプレートはサービスマップを提供するため、キューブはコールグラフとして描画されます(レイヤー横断の階層の起点にもなります)。読み取り専用です — これは 3D-map の設定ではなく、レイヤーテンプレートのプロパティです。",
+  "This layer’s template is disabled.": "この layer のテンプレートは無効化されています。",
   "This OAP serves traces via": "この OAP は次の経由でトレースを提供します:",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "この OAP バージョンはキーワード + タグフィルターのみサポートします。レイヤー + エンティティフィルターを使用するにはアップグレードしてください。",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "このルールにはディスク上にバンドル版がありません。削除すると唯一のコピーが失われ、Horizon では復元できません。",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "トレースの取得に失敗しました。",
   "Traces · Zipkin": "トレース · Zipkin",
   "Traffic": "トラフィック",
+  "translation overlay | translation overlays": "翻訳オーバーレイ",
   "Translations": "翻訳",
   "Tree": "ツリー",
   "Triage": "トリアージ",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "トリガー式",
   "Triggered": "発動回数",
   "Turn Cold off": "Cold をオフにする",
+  "Turn auto-refresh off": "自動更新をオフにする",
+  "Turn auto-refresh on": "自動更新をオンにする",
   "Turn off and delete": "無効にして削除",
   "Turn off {component}?": "{component} を無効にしますか?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Horizon が接続している OAP バックエンドの二ポートビュー。Query / GraphQL (:12800) はすべての可観測性ページを駆動し、管理ホスト (:17128) は DSL 管理、ライブデバッガー、メトリック検査、Dump を制御し、Zipkin / [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "SkyWalking 自身の監視 — OAP クラスタは健全か、内部のメトリクスカタログはどうなっているか、データはどれだけ保持されるか、実行中の設定は何か。",
   "Welcome to SkyWalking": "SkyWalking へようこそ",
   "What each role can do": "各ロールができること",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "Horizon が OAP のテンプレートストアから何をいつ読み込んだか。上のモジュール行はエンドポイントが応答するかどうかを示し、ここでは実際に返ってきた内容を示します。",
   "What you can do": "実行できる操作",
   "When": "日時",
   "Where it runs": "実際の適用先",
@@ -2630,6 +2668,7 @@
   "{count} in window": "ウィンドウ内に {count} 件",
   "{count} layers": "{count} レイヤー",
   "{count} logs": "{count} 件のログ",
+  "{count} refresh failures": "更新の失敗 {count} 件",
   "{count} unpinned → fallback": "未ピン留め {count} 件 → フォールバック",
   "{dur} ms — {pct}% of slowest": "{dur} ms — 最遅の {pct}%",
   "{d}d {h}h ago": "{d} 日 {h} 時間前",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "使用回数",
   "Credentials": "認証情報",
-  "Rate": "レート"
+  "Rate": "レート",
+  "Browser error log": "ブラウザエラーログ",
+  "set by": "設定元",
+  "Zipkin traces": "Zipkin トレース",
+  "Browser error logs": "ブラウザエラーログ",
+  "Minute metrics": "分単位メトリクス",
+  "Hour metrics": "時間単位メトリクス",
+  "Day metrics": "日単位メトリクス",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ これらは OAP の `application.yml` の設定です。このバックエンドはレコード系すべてに 1 つ、メトリクス系すべてに 1 つの保持期間を適用するため、上の数値は実際の値ですが個別には変更できません。データクラスごとの保持期間は BanyanDB のみ対応しています。",
+  "One bydb.yml group per row · widths proportional to total retention": "1 行が bydb.yml の 1 グループ · 幅は合計保持期間に比例"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index fae4e15a..2a1628e9 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -95,7 +95,9 @@
   "AI Assistant": "AI 어시스턴트",
   "AI Assistant & MCP": "AI 어시스턴트와 MCP",
   "AI request failed ({status}).": "AI 요청이 실패했습니다({status}).",
+  "alert page | alert pages": "알림 페이지",
   "An ACTIVE rule must be inactivated first.": "ACTIVE 규칙은 먼저 비활성화해야 합니다.",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "관리자가 비활성화했습니다. Operate → Templates에서 다시 활성화할 수 있습니다.",
   "API dependency": "API 의존성",
   "API dependency chain": "API 의존성 체인",
   "API dependency config": "API 의존성 설정",
@@ -158,7 +160,6 @@
   "All traffic": "모든 트래픽",
   "All verbs": "모든 verb",
   "All versions": "전체 버전",
-  "All {category} ({n})": "전체 {category} ({n})",
   "Allow": "허용",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "다른 탭에서도 이어졌기 때문에 이 버전은 저장되지 않았습니다. 열어서 유지할 버전을 선택하세요.",
   "Always visible": "항상 표시",
@@ -225,13 +226,41 @@
   "Back to Overview": "개요로 돌아가기",
   "Back to list": "목록으로 돌아가기",
   "Back to presets": "프리셋으로 돌아가기",
+  "Could not load the dependency graph.": "의존성 그래프를 불러오지 못했습니다.",
+  "Could not load the deployment topology.": "배포 토폴로지를 불러오지 못했습니다.",
+  "Could not load the instance relationship.": "인스턴스 관계를 불러오지 못했습니다.",
+  "Could not load the topology.": "토폴로지를 불러오지 못했습니다.",
+  "Dashboard templates": "대시보드 템플릿",
+  "Details": "세부 정보",
   "Discard unsaved changes to this rule?": "이 규칙의 저장되지 않은 변경 사항을 취소할까요?",
+  "Endpoint dependency": "Endpoint 의존 관계",
+  "expanding an endpoint": "Endpoint 확장",
+  "Hide details": "세부 정보 숨기기",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon은 OAP에 저장된 내용만 렌더링하므로, 기본 템플릿으로 대체하지 않고 아무것도 표시하지 않습니다.",
+  "Instance relationship": "인스턴스 관계",
+  "layer template | layer templates": "Layer 템플릿",
+  "loaded": "불러옴",
+  "Loaded": "불러옴",
+  "Loading dependencies for “{target}”…": "“{target}”의 의존성을 불러오는 중…",
+  "Loading deployment for “{target}”…": "“{target}”의 배포 토폴로지를 불러오는 중…",
+  "Loading relationship for “{target}”…": "“{target}”의 관계를 불러오는 중…",
+  "Loading topology for “{target}”…": "“{target}”의 토폴로지를 불러오는 중…",
+  "Nothing loaded": "불러온 것이 없음",
+  "OAP could not be reached, so the graph was left as it was.": "OAP에 연결할 수 없어 그래프를 그대로 유지했습니다.",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP가 이 규칙이 정의한 metrics 수집을 중단합니다. 이를 참조하는 대시보드와 알람 규칙은 비게 됩니다.",
+  "overview | overviews": "개요",
   "permanently delete": "영구 삭제",
   "Delete rule": "규칙 삭제",
   "Ranges over {h}h can be slow on a large deployment.": "{h}시간을 넘는 범위는 대규모 배포에서 느릴 수 있습니다.",
+  "reading the deployment graph": "배포 그래프 읽는 중",
+  "reading the endpoint dependency graph": "Endpoint 의존 관계 그래프 읽는 중",
+  "reading the instance relationship graph": "인스턴스 관계 그래프 읽는 중",
+  "reading the service map": "서비스 맵 읽는 중",
+  "Refresh failures": "새로 고침 실패",
+  "Refreshing": "새로 고치는 중",
   "Remove source map?": "source map을 제거할까요?",
   "Requires the source-map:write permission.": "source-map:write 권한이 필요합니다.",
+  "Serving an earlier read": "이전 읽기 결과를 사용 중",
   "Snaps to a group boundary — a group is one hour": "그룹 경계에 맞춰집니다 — 한 그룹은 한 시간입니다",
   "Backend": "백엔드",
   "Backend unreachable.": "백엔드에 연결할 수 없습니다.",
@@ -247,9 +276,7 @@
   "Browse them in": "다음에서 살펴보세요:",
   "Browser": "Browser",
   "Browser Logs": "브라우저 로그",
-  "Browser err log": "브라우저 오류 로그",
   "Browser error": "Browser 오류",
-  "Browser error log": "브라우저 오류 로그",
   "Browser errors": "Browser 오류",
   "Browser local time · {tz}": "브라우저 로컬 시간 · {tz}",
   "Browser local: {tz}": "브라우저 로컬: {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "일시 중지됨",
   "Peer": "피어",
   "Peer addr": "피어 주소",
-  "Per-data-class · widths proportional to total retention": "데이터 클래스별 · 너비는 총 보존 기간에 비례",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "엔드포인트별 대시보드 (dashboards.endpoint 위젯 세트).",
   "Per-instance dashboard (dashboards.instance widget set).": "인스턴스별 대시보드 (dashboards.instance 위젯 세트).",
   "Per-layer setup": "레이어별 설정",
@@ -1543,6 +1569,7 @@
   "Slowest": "느린 순",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "일부 지표를 불러오지 못했습니다 ({total}개 배치 중 {failed}개 실패) — 빈 값은 0이 아니라 사용할 수 없는 값일 수 있습니다.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "일부 지표를 불러오지 못했습니다 ({total}개 배치 중 {failed}개 실패) — 일부 엔드포인트나 링크가 누락되었을 수 있습니다.",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "페이지의 일부를 새로 고치지 못했습니다 — 새로 고침 버튼 옆의 실패 기록을 확인하세요.",
   "Some records were not written in this window:": "이 기간에 기록되지 못한 항목이 있습니다:",
   "Source": "소스",
   "Source maps": "소스 맵",
@@ -1564,6 +1591,7 @@
   "Stage local": "로컬에 저장",
   "Staged {locale} locally.": "{locale} 초안을 로컬에 저장했습니다.",
   "Stale": "불일치",
+  "stale · store unreachable": "오래됨 · 스토어에 연결할 수 없음",
   "Start": "시작",
   "Start a profiling task": "프로파일링 작업 시작",
   "Start a profiling task on a target": "대상에 대한 프로파일링 작업 시작",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "최상위 단일 관문입니다 — 이 JS regex가 제외하는 레이어는 맵에서 빠집니다(아래 미고정 목록에는 \"filtered out\"으로 표시되어 그대로 남습니다). 기본값 {dotStar}은 모두 허용합니다.",
   "The page below is rendered read-only and will fail any save / debug action.": "아래 페이지는 읽기 전용으로 렌더링되며, 저장 / 디버그 작업은 모두 실패합니다.",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "사용자가 사이드바에서 레이어를 선택할 때 보게 되는 레이어별 랜딩 페이지.",
+  "The refresh took too long and was given up on.": "새로 고침이 너무 오래 걸려 중단했습니다.",
+  "The request failed.": "요청이 실패했습니다.",
   "The selected service is not in this layer — pick another one to query.": "선택한 서비스가 이 레이어에 없습니다 — 다른 서비스를 선택해 조회하세요.",
   "The service list accepts at most {n} columns.": "서비스 목록은 최대 {n}개의 열을 허용합니다.",
   "The sign-in could not be verified. Please try again.": "로그인을 검증할 수 없습니다. 다시 시도해 주세요.",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "로그인 공급자가 이메일 주소를 제공하지 않았습니다.",
   "The sign-in provider returned no authorization code.": "로그인 공급자가 인가 코드를 반환하지 않았습니다.",
   "The single catch-all: any layer no tier pins lands here.": "단일 포괄 티어입니다: 어느 티어에도 고정되지 않은 레이어가 모두 여기에 배치됩니다.",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "지금은 스토어를 읽을 수 없어 마지막으로 불러온 템플릿을 표시하고 있습니다. 내장 기본값이 아니라 사용자의 설정이며, 다만 최신이 아닙니다.",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "스토어를 한 번도 성공적으로 읽지 못했으므로 응답이 올 때까지 대시보드, 개요, 맵은 비어 있습니다.",
+  "The template store could not be read.": "템플릿 저장소를 읽을 수 없습니다.",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "상단의 {coldPill} 칩은 읽기 대상을 {hotWarm}에서 {cold}로 전환합니다 — {note}. 따라서 보고 있는 시간 범위가 hot+warm 경계보다 오래된 경우에만 활성화하세요.",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "{tab} 탭은 SkyWalking 네이티브 query-protocol API를 직접 사용하는 기본 제공 화면입니다. 필터와 시간 범위는 운영자가 해당 페이지에서 직접 지정하므로 여기서 설정할 항목이 없습니다.",
   "Theme": "테마",
   "Theme: {name} (your override) — click to change": "테마: {name} (개인 설정) — 클릭해 변경",
   "Theme: {name} — click to change": "테마: {name} — 클릭해 변경",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "이 레이어들의 템플릿은 서비스 맵을 제공하므로, 해당 큐브는 호출 그래프로 렌더링되고 레이어 간 계층 구조의 기반이 됩니다. 읽기 전용입니다 — 이는 레이어 템플릿의 속성이며 3D-map 설정이 아닙니다.",
+  "This layer’s template is disabled.": "이 layer의 템플릿이 비활성화되어 있습니다.",
   "This OAP serves traces via": "이 OAP는 다음을 통해 트레이스를 제공합니다:",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "이 OAP 버전은 키워드 + 태그 필터만 지원합니다. 레이어 + 엔터티 필터를 사용하려면 업그레이드하세요.",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "이 규칙은 디스크에 번들 버전이 없습니다. 삭제하면 유일한 사본이 사라지며 Horizon은 복원할 수 없습니다.",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "트레이스 피드를 가져오지 못했습니다.",
   "Traces · Zipkin": "트레이스 · Zipkin",
   "Traffic": "트래픽",
+  "translation overlay | translation overlays": "번역 오버레이",
   "Translations": "번역",
   "Tree": "트리",
   "Triage": "트리아지",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "트리거 표현식",
   "Triggered": "트리거됨",
   "Turn Cold off": "Cold 끄기",
+  "Turn auto-refresh off": "자동 새로 고침 끄기",
+  "Turn auto-refresh on": "자동 새로 고침 켜기",
   "Turn off and delete": "끄고 삭제",
   "Turn off {component}?": "{component}을(를) 끌까요?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Horizon이 연결된 OAP 백엔드의 두 포트 보기입니다. Query / GraphQL (:12800)은 모든 관측 페이지를 구동하고, 관리 호스트 (:17128)는 DSL 관리·라이브 디버거·메트릭 검사·덤프를 통제하며, Zipkin / O [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "SkyWalking 자체를 지켜봅니다 — OAP 클러스터가 정상인지, 내부 메트릭 카탈로그는 어떤 모습인지, 데이터는 얼마나 보관되는지, 어떤 설정으로 실행 중인지.",
   "Welcome to SkyWalking": "SkyWalking에 오신 것을 환영합니다",
   "What each role can do": "각 역할이 할 수 있는 작업",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "Horizon이 OAP 템플릿 스토어에서 무엇을 언제 불러왔는지. 위의 모듈 행은 엔드포인트의 응답 여부를 나타내고, 여기서는 실제로 무엇이 돌아왔는지 보여줍니다.",
   "What you can do": "수행 가능한 작업",
   "When": "시각",
   "Where it runs": "실행되는 위치",
@@ -2630,6 +2668,7 @@
   "{count} in window": "구간 내 {count}개",
   "{count} layers": "레이어 {count}개",
   "{count} logs": "로그 {count}개",
+  "{count} refresh failures": "새로 고침 실패 {count}건",
   "{count} unpinned → fallback": "미고정 {count}개 → 폴백",
   "{dur} ms — {pct}% of slowest": "{dur} ms — 가장 느린 트레이스의 {pct}%",
   "{d}d {h}h ago": "{d}일 {h}시간 전",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "사용 횟수",
   "Credentials": "자격 증명",
-  "Rate": "비율"
+  "Rate": "비율",
+  "Browser error log": "브라우저 오류 로그",
+  "set by": "설정 위치",
+  "Zipkin traces": "Zipkin 트레이스",
+  "Browser error logs": "브라우저 오류 로그",
+  "Minute metrics": "분 단위 메트릭",
+  "Hour metrics": "시간 단위 메트릭",
+  "Day metrics": "일 단위 메트릭",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ 이는 OAP `application.yml` 설정입니다. 이 백엔드는 레코드 전체에 하나, 메트릭 전체에 하나의 보존 기간을 적용하므로 위 수치는 실제 값이지만 개별로 조정할 수 없습니다. 데이터 클래스별 보존 기간은 BanyanDB에서만 지원합니다.",
+  "One bydb.yml group per row · widths proportional to total retention": "한 행이 bydb.yml 그룹 하나 · 너비는 총 보존 기간에 비례"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 5d06979e..393756d0 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -95,7 +95,9 @@
   "AI Assistant": "Assistente de IA",
   "AI Assistant & MCP": "Assistente de IA e MCP",
   "AI request failed ({status}).": "A solicitação de IA falhou ({status}).",
+  "alert page | alert pages": "página de alerta | páginas de alerta",
   "An ACTIVE rule must be inactivated first.": "Uma regra ACTIVE precisa ser inativada primeiro.",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "Um administrador o desativou — reative-o em Operate → Templates.",
   "API dependency": "Dependência de API",
   "API dependency chain": "Cadeia de dependências de API",
   "API dependency config": "Configuração de dependências de API",
@@ -158,7 +160,6 @@
   "All traffic": "Todo o tráfego",
   "All verbs": "Todos os verbs",
   "All versions": "Todas as versões",
-  "All {category} ({n})": "Todos os {category} ({n})",
   "Allow": "Permitir",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "Também foi continuada em outra aba, então esta versão não foi salva. Abra-a para escolher qual versão manter.",
   "Always visible": "Sempre visível",
@@ -225,13 +226,41 @@
   "Back to Overview": "Voltar à visão geral",
   "Back to list": "Voltar à lista",
   "Back to presets": "Voltar aos pré-ajustes",
+  "Could not load the dependency graph.": "Não foi possível carregar o grafo de dependências.",
+  "Could not load the deployment topology.": "Não foi possível carregar a topologia de implantação.",
+  "Could not load the instance relationship.": "Não foi possível carregar a relação entre instâncias.",
+  "Could not load the topology.": "Não foi possível carregar a topologia.",
+  "Dashboard templates": "Templates de painéis",
+  "Details": "Detalhes",
   "Discard unsaved changes to this rule?": "Descartar as alterações não salvas desta regra?",
+  "Endpoint dependency": "Dependência de endpoints",
+  "expanding an endpoint": "expandindo um endpoint",
+  "Hide details": "Ocultar detalhes",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "O Horizon renderiza o que o OAP guarda, então não exibe nada em vez de recorrer aos templates padrão.",
+  "Instance relationship": "Relação entre instâncias",
+  "layer template | layer templates": "template de layer | templates de layer",
+  "loaded": "carregado",
+  "Loaded": "Carregado",
+  "Loading dependencies for “{target}”…": "Carregando as dependências de “{target}”…",
+  "Loading deployment for “{target}”…": "Carregando a topologia de implantação de “{target}”…",
+  "Loading relationship for “{target}”…": "Carregando a relação de “{target}”…",
+  "Loading topology for “{target}”…": "Carregando a topologia de “{target}”…",
+  "Nothing loaded": "Nada carregado",
+  "OAP could not be reached, so the graph was left as it was.": "Não foi possível acessar o OAP, então o grafo foi mantido como estava.",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "O OAP para de coletar as metrics definidas por esta regra; painéis e regras de alarme que as referenciam ficam vazios.",
+  "overview | overviews": "visão geral | visões gerais",
   "permanently delete": "excluir permanentemente",
   "Delete rule": "Excluir regra",
   "Ranges over {h}h can be slow on a large deployment.": "Intervalos acima de {h} h podem ficar lentos em uma implantação grande.",
+  "reading the deployment graph": "lendo o grafo de implantação",
+  "reading the endpoint dependency graph": "lendo o grafo de dependência de endpoints",
+  "reading the instance relationship graph": "lendo o grafo de relação entre instâncias",
+  "reading the service map": "lendo o mapa de serviços",
+  "Refresh failures": "Falhas de atualização",
+  "Refreshing": "Atualizando",
   "Remove source map?": "Remover o source map?",
   "Requires the source-map:write permission.": "Requer a permissão source-map:write.",
+  "Serving an earlier read": "Servindo uma leitura anterior",
   "Snaps to a group boundary — a group is one hour": "Ajusta ao limite do grupo: cada grupo dura uma hora",
   "Backend": "Backend",
   "Backend unreachable.": "Backend inacessível.",
@@ -247,9 +276,7 @@
   "Browse them in": "Navegue por elas em",
   "Browser": "Browser",
   "Browser Logs": "Logs do navegador",
-  "Browser err log": "Log de erros do navegador",
   "Browser error": "Erro de Browser",
-  "Browser error log": "Log de erros do navegador",
   "Browser errors": "Erros de Browser",
   "Browser local time · {tz}": "Hora local do navegador · {tz}",
   "Browser local: {tz}": "Local do navegador: {tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "Pausado",
   "Peer": "Peer",
   "Peer addr": "Endereço do peer",
-  "Per-data-class · widths proportional to total retention": "Por classe de dado · larguras proporcionais à retenção total",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "Painel por endpoint (conjunto de widgets dashboards.endpoint).",
   "Per-instance dashboard (dashboards.instance widget set).": "Painel por instância (conjunto de widgets dashboards.instance).",
   "Per-layer setup": "Configuração por camada",
@@ -1543,6 +1569,7 @@
   "Slowest": "Mais lentos",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "Não foi possível carregar algumas métricas ({failed} de {total} lotes falharam) — valores em branco podem estar indisponíveis, não são zero.",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "Não foi possível carregar algumas métricas ({failed} de {total} lotes falharam) — alguns endpoints ou ligações podem estar ausentes.",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "Parte da página não pôde ser atualizada — veja as falhas ao lado do botão de atualizar.",
   "Some records were not written in this window:": "Alguns registros não foram gravados neste intervalo:",
   "Source": "Origem",
   "Source maps": "Source maps",
@@ -1564,6 +1591,7 @@
   "Stage local": "Salvar localmente",
   "Staged {locale} locally.": "Rascunho {locale} salvo localmente.",
   "Stale": "Obsoleto",
+  "stale · store unreachable": "desatualizado · repositório inacessível",
   "Start": "Início",
   "Start a profiling task": "Iniciar uma tarefa de profiling",
   "Start a profiling task on a target": "Iniciar uma tarefa de profiling em um alvo",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "O único filtro de nível superior — uma camada excluída por este regex JS fica fora do mapa (ainda aparece abaixo em Não fixadas, marcada como \"filtered out\"). O padrão {dotStar} admite todas.",
   "The page below is rendered read-only and will fail any save / debug action.": "A página abaixo é exibida em modo somente leitura e qualquer ação de salvar ou depurar irá falhar.",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "As páginas iniciais por camada que os usuários veem ao escolher uma camada na barra lateral.",
+  "The refresh took too long and was given up on.": "A atualização demorou demais e foi abandonada.",
+  "The request failed.": "A requisição falhou.",
   "The selected service is not in this layer — pick another one to query.": "O serviço selecionado não está nesta camada — escolha outro para consultar.",
   "The service list accepts at most {n} columns.": "A lista de serviços aceita no máximo {n} colunas.",
   "The sign-in could not be verified. Please try again.": "Não foi possível verificar o início de sessão. Tente novamente.",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "O fornecedor não forneceu qualquer endereço de e-mail.",
   "The sign-in provider returned no authorization code.": "O fornecedor não devolveu qualquer código de autorização.",
   "The single catch-all: any layer no tier pins lands here.": "O único nível coringa: toda camada que nenhum nível fixa cai aqui.",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "O repositório não pode ser lido agora, então estes são os templates carregados por último. São os seus, não os padrões do pacote — apenas desatualizados.",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "O repositório nunca foi lido com sucesso, então painéis, visões gerais e mapas ficam vazios até ele responder.",
+  "The template store could not be read.": "Não foi possível ler o repositório de templates.",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "A pill {coldPill} da topbar troca a leitura de {hotWarm} para {cold} — {note}, portanto habilite-a apenas quando o intervalo de tempo que você está examinando for mais antigo que o limite hot+warm.",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "A aba {tab} é uma visão nativa que usa diretamente as APIs do query-protocol do SkyWalking. Os operadores configuram filtros e intervalo de tempo em runtime, na própria página; não há nada para configurar aqui.",
   "Theme": "Tema",
   "Theme: {name} (your override) — click to change": "Tema: {name} (seu ajuste) — clique para alterar",
   "Theme: {name} — click to change": "Tema: {name} — clique para alterar",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "Os templates destas camadas fornecem um mapa de serviços, então seus cubos são renderizados como um grafo de chamadas (e alimentam a hierarquia entre camadas). Somente leitura — é uma propriedade do template da camada, não uma configuração do 3D-map.",
+  "This layer’s template is disabled.": "O template desta layer está desativado.",
   "This OAP serves traces via": "Este OAP entrega traces via",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "Esta versão do OAP suporta apenas filtros por palavra-chave + tag. Atualize para filtros por camada + entidade.",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "Esta regra não tem versão incluída em disco — excluí-la remove a única cópia, e o Horizon não consegue restaurá-la.",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "O feed de traces falhou.",
   "Traces · Zipkin": "Traces · Zipkin",
   "Traffic": "Tráfego",
+  "translation overlay | translation overlays": "sobreposição de tradução | sobreposições de tradução",
   "Translations": "Traduções",
   "Tree": "Árvore",
   "Triage": "Triagem",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "Expressão de disparo",
   "Triggered": "Disparada",
   "Turn Cold off": "Desativar Cold",
+  "Turn auto-refresh off": "Desativar a atualização automática",
+  "Turn auto-refresh on": "Ativar a atualização automática",
   "Turn off and delete": "Desativar e excluir",
   "Turn off {component}?": "Desativar {component}?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "Visão de duas portas do backend OAP ao qual o Horizon está conectado. Query / GraphQL (:12800) alimenta cada página de observabilidade;  [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "Acompanhar o próprio SkyWalking: o cluster OAP está saudável, como está o catálogo interno de métricas, por quanto tempo os dados são mantidos, qual é a configuração em execução.",
   "Welcome to SkyWalking": "Bem-vindo ao SkyWalking",
   "What each role can do": "O que cada papel pode fazer",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "O que o Horizon carregou do repositório de templates do OAP, e quando. A linha do módulo acima diz se o endpoint responde; esta diz o que voltou dele.",
   "What you can do": "O que você pode fazer",
   "When": "Quando",
   "Where it runs": "Onde é executada",
@@ -2630,6 +2668,7 @@
   "{count} in window": "{count} na janela",
   "{count} layers": "{count} camadas",
   "{count} logs": "{count} logs",
+  "{count} refresh failures": "{count} falhas de atualização",
   "{count} unpinned → fallback": "{count} não fixadas → fallback",
   "{dur} ms — {pct}% of slowest": "{dur} ms — {pct}% do mais lento",
   "{d}d {h}h ago": "há {d}d {h}h",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "Usos",
   "Credentials": "Credenciais",
-  "Rate": "Taxa"
+  "Rate": "Taxa",
+  "Browser error log": "Log de erros do navegador",
+  "set by": "definido por",
+  "Zipkin traces": "Traces Zipkin",
+  "Browser error logs": "Logs de erros do navegador",
+  "Minute metrics": "Métricas por minuto",
+  "Hour metrics": "Métricas por hora",
+  "Day metrics": "Métricas por dia",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ São definições do `application.yml` do OAP. Este backend mantém uma retenção para tudo o que é um record e outra para tudo o que é uma métrica, portanto os valores acima são reais mas não podem ser ajustados separadamente. A retenção por  [...]
+  "One bydb.yml group per row · widths proportional to total retention": "Um grupo do bydb.yml por linha · larguras proporcionais à retenção total"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 2d623421..fe140c61 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -95,7 +95,9 @@
   "AI Assistant": "AI 助手",
   "AI Assistant & MCP": "AI 助手与 MCP",
   "AI request failed ({status}).": "AI 请求失败({status})。",
+  "alert page | alert pages": "告警页面",
   "An ACTIVE rule must be inactivated first.": "ACTIVE 规则必须先停用。",
+  "An administrator turned it off — re-enable it under Operate → Templates.": "管理员关闭了它 — 可在 Operate → Templates 中重新启用。",
   "API dependency": "API 依赖",
   "API dependency chain": "API 依赖链",
   "API dependency config": "API 依赖配置",
@@ -158,7 +160,6 @@
   "All traffic": "全部流量",
   "All verbs": "全部 verb",
   "All versions": "全部版本",
-  "All {category} ({n})": "全部 {category}({n})",
   "Allow": "允许",
   "Also continued in another tab, so this version was not saved. Open it to pick the version to keep.": "该对话在另一个标签页中也被继续,因此此版本未保存。打开它以选择要保留的版本。",
   "Always visible": "始终显示",
@@ -225,13 +226,41 @@
   "Back to Overview": "返回总览",
   "Back to list": "返回列表",
   "Back to presets": "返回预设",
+  "Could not load the dependency graph.": "无法加载依赖图。",
+  "Could not load the deployment topology.": "无法加载部署拓扑。",
+  "Could not load the instance relationship.": "无法加载实例关系。",
+  "Could not load the topology.": "无法加载拓扑。",
+  "Dashboard templates": "仪表板模板",
+  "Details": "详情",
   "Discard unsaved changes to this rule?": "放弃对该规则未保存的修改?",
+  "Endpoint dependency": "端点依赖",
+  "expanding an endpoint": "展开端点",
+  "Hide details": "隐藏详情",
+  "Horizon renders what OAP holds, so it is serving nothing rather than defaults.": "Horizon 只渲染 OAP 中保存的内容,因此这里不会退回到内置默认模板,而是什么都不显示。",
+  "Instance relationship": "实例关系",
+  "layer template | layer templates": "Layer 模板",
+  "loaded": "已加载",
+  "Loaded": "已加载",
+  "Loading dependencies for “{target}”…": "正在加载“{target}”的依赖…",
+  "Loading deployment for “{target}”…": "正在加载“{target}”的部署拓扑…",
+  "Loading relationship for “{target}”…": "正在加载“{target}”的关系…",
+  "Loading topology for “{target}”…": "正在加载“{target}”的拓扑…",
+  "Nothing loaded": "没有加载任何内容",
+  "OAP could not be reached, so the graph was left as it was.": "无法访问 OAP,因此保留了原有的拓扑图。",
   "OAP stops collecting the metrics this rule defines; dashboards and alarm rules that reference them go empty.": "OAP 将停止采集该规则定义的 metrics;引用这些 metrics 的仪表板和告警规则将变为空。",
+  "overview | overviews": "概览",
   "permanently delete": "永久删除",
   "Delete rule": "删除规则",
   "Ranges over {h}h can be slow on a large deployment.": "超过 {h} 小时的时间范围在大规模部署下可能较慢。",
+  "reading the deployment graph": "读取部署拓扑",
+  "reading the endpoint dependency graph": "读取端点依赖拓扑",
+  "reading the instance relationship graph": "读取实例关系拓扑",
+  "reading the service map": "读取服务拓扑",
+  "Refresh failures": "刷新失败记录",
+  "Refreshing": "刷新中",
   "Remove source map?": "移除 source map?",
   "Requires the source-map:write permission.": "需要 source-map:write 权限。",
+  "Serving an earlier read": "正在使用较早的读取结果",
   "Snaps to a group boundary — a group is one hour": "自动对齐到分组边界 —— 每组为一小时",
   "Backend": "后端",
   "Backend unreachable.": "后端不可达。",
@@ -247,9 +276,7 @@
   "Browse them in": "可在以下位置浏览:",
   "Browser": "Browser",
   "Browser Logs": "浏览器日志",
-  "Browser err log": "浏览器错误日志",
   "Browser error": "Browser 错误",
-  "Browser error log": "浏览器错误日志",
   "Browser errors": "Browser 错误",
   "Browser local time · {tz}": "浏览器本地时间 · {tz}",
   "Browser local: {tz}": "浏览器本地:{tz}",
@@ -1148,7 +1175,6 @@
   "Paused": "已暂停",
   "Peer": "对端",
   "Peer addr": "对端地址",
-  "Per-data-class · widths proportional to total retention": "按数据类别 · 宽度与总保留期成正比",
   "Per-endpoint dashboard (dashboards.endpoint widget set).": "按端点的仪表板(dashboards.endpoint 组件集)。",
   "Per-instance dashboard (dashboards.instance widget set).": "按实例的仪表板(dashboards.instance 组件集)。",
   "Per-layer setup": "每个 layer 的设置",
@@ -1543,6 +1569,7 @@
   "Slowest": "最慢",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — blank values may be unavailable, not zero.": "部分指标加载失败({total} 批中有 {failed} 批失败),空白值可能表示数据暂不可用,而非零值。",
   "Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.": "部分指标加载失败({total} 批中有 {failed} 批失败),部分端点或链路可能缺失。",
+  "Some of the page could not be refreshed — see the failures beside the refresh button.": "页面的部分内容未能刷新 — 请查看刷新按钮旁的失败记录。",
   "Some records were not written in this window:": "该时间范围内有部分记录未能写入:",
   "Source": "来源",
   "Source maps": "Source maps",
@@ -1564,6 +1591,7 @@
   "Stage local": "暂存到本地",
   "Staged {locale} locally.": "已将 {locale} 草稿暂存到本地。",
   "Stale": "已失效",
+  "stale · store unreachable": "已过期 · 无法访问模板存储",
   "Start": "开始",
   "Start a profiling task": "启动一次性能剖析任务",
   "Start a profiling task on a target": "在目标上启动一次性能剖析任务",
@@ -1635,6 +1663,8 @@
   "The one top-level gate — a layer this JS regex excludes is off the map (it still shows below in Unpinned, tagged \"filtered out\"). Default {dotStar} admits all.": "唯一的顶级开关 —— 被这段 JS regex 排除的 layer 不会出现在地图上(它仍会显示在下方的“未固定”中,并标记为“filtered out”)。默认 {dotStar} 放行所有 layer。",
   "The page below is rendered read-only and will fail any save / debug action.": "下方页面以只读方式渲染,任何保存或调试操作都将失败。",
   "The per-layer landing pages users see when they pick a layer in the sidebar.": "用户在侧边栏选择某个 layer 时看到的对应着陆页。",
+  "The refresh took too long and was given up on.": "刷新耗时过长,已放弃。",
+  "The request failed.": "请求失败。",
   "The selected service is not in this layer — pick another one to query.": "所选服务不在该层中 —— 请另选一个服务再查询。",
   "The service list accepts at most {n} columns.": "服务列表最多支持 {n} 个列。",
   "The sign-in could not be verified. Please try again.": "无法校验此次登录,请重试。",
@@ -1642,12 +1672,16 @@
   "The sign-in provider did not supply an email address.": "登录提供方未提供邮箱地址。",
   "The sign-in provider returned no authorization code.": "登录提供方未返回授权码。",
   "The single catch-all: any layer no tier pins lands here.": "唯一的兜底层组:任何未被固定到具体层组的 layer 都会落到这里。",
+  "The store cannot be read right now, so these are the templates that loaded last. They are yours, not the bundled defaults — only out of date.": "当前无法读取模板存储,因此这里显示的是最近一次成功加载的模板。它们是你自己的配置,而非内置默认模板,只是不是最新的。",
+  "The store has never been read successfully, so dashboards, overviews and maps stay empty until it answers.": "模板存储从未成功读取过,因此在它恢复响应之前,仪表板、概览和拓扑图都会保持为空。",
+  "The template store could not be read.": "无法读取模板存储。",
   "The topbar {coldPill} pill swaps the read from {hotWarm} to {cold} — {note}, so only enable it when the time range you're looking at is older than the hot+warm boundary.": "顶栏的 {coldPill} 胶囊会将读取从 {hotWarm} 切换到 {cold} —— {note},因此仅当你查看的时间范围早于 hot+warm 边界时再启用它。",
   "The {tab} tab is a built-in view that uses SkyWalking-native query-protocol APIs directly. Operators configure filters and time range at runtime from the page itself; nothing to wire up here.": "{tab} 标签页是直接使用 SkyWalking 原生 query-protocol API 的内置视图。筛选条件与时间范围由运维人员在页面上实时设置,此处无需配置。",
   "Theme": "主题",
   "Theme: {name} (your override) — click to change": "主题:{name}(你的自定义设置)—— 点击更换",
   "Theme: {name} — click to change": "主题:{name} —— 点击更换",
   "These layers' templates provide a service map, so their cubes render as a call graph (and seed the cross-layer hierarchy). Read-only — it's a layer-template property, not a 3D-map setting.": "这些 layer 的模板提供服务地图,因此其方块会渲染为调用关系图(并作为跨 layer 层级关系的起点)。只读 —— 这是 layer 模板的属性,而非 3D-map 的设置。",
+  "This layer’s template is disabled.": "该 layer 的模板已被禁用。",
   "This OAP serves traces via": "此 OAP 通过以下方式提供 trace 数据:",
   "This OAP version supports keyword + tag filters only. Upgrade for layer + entity filters.": "此 OAP 版本仅支持关键词 + 标签过滤。升级后可使用 layer + 实体过滤。",
   "This rule has no bundled version on disk — deleting it removes the only copy, and Horizon cannot restore it.": "该规则在磁盘上没有内置版本 — 删除后将不存在任何副本,Horizon 无法恢复。",
@@ -1729,6 +1763,7 @@
   "Traces feed failed.": "trace 数据获取失败。",
   "Traces · Zipkin": "Trace · Zipkin",
   "Traffic": "流量",
+  "translation overlay | translation overlays": "翻译覆盖",
   "Translations": "翻译",
   "Tree": "树状",
   "Triage": "排查",
@@ -1739,6 +1774,8 @@
   "Trigger expression": "触发表达式",
   "Triggered": "已触发",
   "Turn Cold off": "关闭 Cold",
+  "Turn auto-refresh off": "关闭自动刷新",
+  "Turn auto-refresh on": "开启自动刷新",
   "Turn off and delete": "关闭并删除",
   "Turn off {component}?": "关闭{component}?",
   "Two-port view of the OAP backend horizon is connected to. Query / GraphQL (:12800) drives every observability page; the admin host (:17128) gates DSL management, Live debugger, Metrics inspect, and Dump; the Zipkin / OTLP endpoint feeds only the Zipkin trace menu. All three are polled independently — if one shows red the others can still be green.": "与 horizon 相连的 OAP 后端的双端口视图。Query / GraphQL(:12800)支撑所有可观测性页面;管理端(:17128)控制 DSL 管理、实时调试器、指标检查与 Dump;Zipkin / OTLP 端点仅为 Zipkin Trace 菜单供数据 [...]
@@ -1803,6 +1840,7 @@
   "Watching SkyWalking itself: is the OAP cluster healthy, what does the internal metric catalog look like, how long is data kept, what is the running configuration.": "关注 SkyWalking 自身:OAP 集群是否健康、内部指标目录是什么样、数据保留多久、当前运行的是什么配置。",
   "Welcome to SkyWalking": "欢迎使用 SkyWalking",
   "What each role can do": "每个角色能做什么",
+  "What Horizon has loaded from OAP's template store, and when. The module row above says whether the endpoint answers; this says what came back from it.": "Horizon 从 OAP 模板存储中加载了哪些内容,以及加载时间。上方的模块行说明该接口是否响应;这里说明实际返回了什么。",
   "What you can do": "你能做什么",
   "When": "时间",
   "Where it runs": "实际运行范围",
@@ -2630,6 +2668,7 @@
   "{count} in window": "时间窗口内 {count} 条",
   "{count} layers": "{count} 个 layer",
   "{count} logs": "{count} 条日志",
+  "{count} refresh failures": "{count} 条刷新失败记录",
   "{count} unpinned → fallback": "{count} 个未固定 → 兜底",
   "{dur} ms — {pct}% of slowest": "{dur} ms — 占最慢值的 {pct}%",
   "{d}d {h}h ago": "{d} 天 {h} 小时前",
@@ -2825,5 +2864,14 @@
   "Token": "Token",
   "Uses": "使用次数",
   "Credentials": "凭证",
-  "Rate": "速率"
+  "Rate": "速率",
+  "Browser error log": "浏览器错误日志",
+  "set by": "由",
+  "Zipkin traces": "Zipkin 链路",
+  "Browser error logs": "浏览器错误日志",
+  "Minute metrics": "分钟指标",
+  "Hour metrics": "小时指标",
+  "Day metrics": "天指标",
+  "ⓘ These are OAP `application.yml` settings. This backend keeps one retention for everything a record holds and one for everything a metric holds, so the figures above are real but cannot be tuned apart. Retention per data class is BanyanDB-only.": "ⓘ 这些是 OAP `application.yml` 中的配置项。该后端对记录类数据统一使用一个保留期,对指标类数据统一使用另一个,因此上面的数值是真实的,但无法分别调整。按数据类别分别设置保留期仅 BanyanDB 支持。",
+  "One bydb.yml group per row · widths proportional to total retention": "每行对应一个 bydb.yml group · 条宽与总保留期成比例"
 }
diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 425f728b..65f4e041 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -212,16 +212,53 @@ const focusWindowMinutes = computed<number | null>(() =>
   embedded.value ? (props.focusWindowMinutes ?? 60) : null,
 );
 const replayDataRef = computed<EndpointDependencyResponse | null>(() => props.replayData ?? null);
-const { nodes: baseNodes, calls: baseCalls, isLoading, isFetching, data } = useLayerEndpointDependency(
+const {
+  nodes: baseNodes,
+  calls: baseCalls,
+  isFetching,
+  phase,
+  data,
+  refetch,
+  acceptedSnapshot,
+  predicateGeneration,
+  previewConfig,
+  baseWindow,
+} = useLayerEndpointDependency(
   layerKey,
   service,
   selectedEndpoint,
   focusWindowMinutes,
   replayDataRef,
 );
+/**
+ * Advances every time a NEW base graph is committed.
+ *
+ * The identity of the snapshot object is the signal: a successful round
+ * replaces it, a failed one leaves the same object in place. So this counts
+ * bases that actually reached the screen, which is exactly what the expansions
+ * hanging off them need to watch.
+ */
+const baseSnapshotVersion = ref(0);
+watch(acceptedSnapshot, () => {
+  baseSnapshotVersion.value += 1;
+});
 const reachable = computed(() => data.value?.reachable !== false);
+/**
+ * WHY it is empty, when the answer is not "OAP could not be read".
+ *
+ * A disabled template and an unreachable store both arrive as an unreachable
+ * response, and telling an operator to check OAP when an administrator turned
+ * the layer off sends them to the wrong place entirely.
+ */
+const blocked = computed(() => data.value?.blocked ?? null);
 const errorText = computed(() => data.value?.error ?? null);
-const metricsPartial = computed(() => data.value?.metricsPartial ?? null);
+// Fields that DESCRIBE THE DRAWN GRAPH come from the snapshot; only the fields
+// that describe the latest ATTEMPT (reachable / blocked / error) come from
+// `data`. Reading them all from `data` mixed the two: after a failed round the
+// nodes were still the snapshot's while the config, size warning and partial-
+// metric notice came from the empty failure — so a drawn graph lost its metric
+// definitions and its warnings the moment a refresh could not be read.
+const metricsPartial = computed(() => acceptedSnapshot.value?.metricsPartial ?? null);
 
 // ── Interactive expansion + merged graph. The expansion engine owns the
 // per-node fetch lifecycle (one `getEndpointDependencies` call returns a
@@ -237,22 +274,63 @@ const {
   hasExpansion,
   isExhausted,
   isLoadingExpansion,
+  expanding,
   expandNode,
 } = useEndpointDependencyExpansion({
   layerKey,
   baseNodes,
   baseCalls,
   selectedEndpoint,
+  predicateGeneration,
+  previewConfig,
+  baseWindow,
+  baseSnapshotVersion,
   onFocusReset: () => {
-    dragOffsets.value = new Map();
+    dragAnchors.value = new Map();
     selectedNodeId.value = null;
     selectedCallId.value = null;
   },
 });
 
+/**
+ * The focus endpoint is only part of the question.
+ *
+ * `onFocusReset` above fires on the endpoint alone, so a time-range switch, a
+ * preview edit or a Hot→Cold flip left the nodes sitting where the operator
+ * had dragged them under the previous read, and the detail panel open on a
+ * selection made against it.
+ */
+watch(predicateGeneration, () => {
+  dragAnchors.value = new Map();
+  selectedNodeId.value = null;
+  selectedCallId.value = null;
+});
+
+/**
+ * A placement whose node is gone goes with it.
+ *
+ * Pruned against the RAW graph, not the laid-out or filtered set: a node hidden
+ * behind a display filter, or pushed out of a column by the per-column cap, is
+ * still in the graph and the operator's placement for it must survive. Without
+ * this the anchor was kept for ever and re-applied if that id ever came back —
+ * a node jumping to a position chosen for a graph nobody remembers.
+ */
+watch(
+  // The MERGED graph — base plus every expansion — because an expanded node is
+  // as draggable as a base one and its placement is as much the operator's.
+  () => nodes.value.map((n) => n.id),
+  (ids) => {
+    if (dragAnchors.value.size === 0) return;
+    const live = new Set(ids);
+    const kept = new Map<string, { x: number; y: number }>();
+    for (const [id, at] of dragAnchors.value) if (live.has(id)) kept.set(id, at);
+    if (kept.size !== dragAnchors.value.size) dragAnchors.value = kept;
+  },
+);
+
 // Config (operator-edited layer JSON): a role → metric def lookup drives
 // every visual channel, same binding as the service-map view.
-const cfg = computed(() => data.value?.config ?? {
+const cfg = computed(() => acceptedSnapshot.value?.config ?? {
   nodeMetrics: [] as TopologyMetricDef[],
   linkMetrics: [] as TopologyMetricDef[],
 });
@@ -307,10 +385,13 @@ function edgeVal(c: EndpointDependencyCall, def: TopologyMetricDef | null): numb
 // ── Focused endpoint id (resolved by BFF; we surface the same id
 // that came back so highlighting stays consistent even if the BFF
 // picked the closest fuzzy match).
-const focusedId = computed<string | null>(() => data.value?.endpointId ?? null);
+// From the SNAPSHOT. Off the latest attempt it went null the moment a refresh
+// failed — which dropped the focus highlight and, through the watcher below,
+// refit the viewport. A failed round is not allowed to move the camera.
+const focusedId = computed<string | null>(() => acceptedSnapshot.value?.endpointId ?? null);
 
 // Drag offset layers on the BFS layout so edges (which read displayPos) follow.
-const dragOffsets = ref<Map<string, { dx: number; dy: number }>>(new Map());
+const dragAnchors = ref<Map<string, { x: number; y: number }>>(new Map());
 
 const {
   layoutNodes,
@@ -329,7 +410,7 @@ const {
   focusedId,
   centerDef,
   nodeVal,
-  dragOffsets,
+  dragAnchors,
   t,
 });
 // Embedded (chat) mode caps the graph card to a bounded stage; the SVG fits +
@@ -473,13 +554,16 @@ const selectedNodeId = ref<string | null>(null);
 // via the view scale. Under the 3px threshold it's a click → toggle
 // selection (the box has no @click any more).
 const nodeDragId = ref<string | null>(null);
-let nodeDragStart = { x: 0, y: 0, baseDx: 0, baseDy: 0, moved: false };
+let nodeDragStart = { x: 0, y: 0, baseX: 0, baseY: 0, moved: false };
 function onNodePointerDown(e: PointerEvent, id: string): void {
   if (e.button !== 0) return;
   e.stopPropagation(); // don't also start a background pan
-  const off = dragOffsets.value.get(id) ?? { dx: 0, dy: 0 };
+  // Start from where the node IS — its placement if it has one, otherwise the
+  // position the column layout gave it.
+  const at = dragAnchors.value.get(id) ?? nodePos.value.get(id);
+  if (!at) return;
   nodeDragId.value = id;
-  nodeDragStart = { x: e.clientX, y: e.clientY, baseDx: off.dx, baseDy: off.dy, moved: false };
+  nodeDragStart = { x: e.clientX, y: e.clientY, baseX: at.x, baseY: at.y, moved: false };
   (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
 }
 function onNodePointerMove(e: PointerEvent): void {
@@ -489,9 +573,12 @@ function onNodePointerMove(e: PointerEvent): void {
   if (!nodeDragStart.moved && Math.hypot(dxs, dys) > 3) nodeDragStart.moved = true;
   if (!nodeDragStart.moved) return;
   const { scale } = viewMetrics();
-  const next = new Map(dragOffsets.value);
-  next.set(nodeDragId.value, { dx: nodeDragStart.baseDx + dxs / scale, dy: nodeDragStart.baseDy + dys / scale });
-  dragOffsets.value = next;
+  const next = new Map(dragAnchors.value);
+  next.set(nodeDragId.value, {
+    x: nodeDragStart.baseX + dxs / scale,
+    y: nodeDragStart.baseY + dys / scale,
+  });
+  dragAnchors.value = next;
 }
 function onNodePointerUp(e: PointerEvent, id: string): void {
   const el = e.currentTarget as Element;
@@ -698,7 +785,16 @@ function edgeRowCrosshair(rowId: string): number | null {
             class="ep-row"
             :class="{ on: selectedEndpoint === e.name }"
           >
-            <button class="ep-pick" type="button" @click="setSelectedEndpoint(e.name)">
+            <!-- Disabled while an expansion is in flight: a focus change here
+                 is exactly the race the epoch guard exists to catch, and not
+                 offering it is better than catching it. -->
+            <button
+              class="ep-pick"
+              type="button"
+              :disabled="expanding"
+              :title="expanding ? t('Loading…') : undefined"
+              @click="setSelectedEndpoint(e.name)"
+            >
               {{ e.name }}
             </button>
           </li>
@@ -710,8 +806,18 @@ function edgeRowCrosshair(rowId: string): number | null {
     </section>
 
     <div v-if="!reachable" class="banner err">
-      <strong>{{ t('OAP unreachable.') }}</strong>
-      {{ errorText ?? t('API dependency feed failed — check the BFF and OAP.') }}
+      <template v-if="blocked === 'layer-disabled'">
+        <strong>{{ t('This layer’s template is disabled.') }}</strong>
+        {{ t('An administrator turned it off — re-enable it under Operate → Templates.') }}
+      </template>
+      <template v-else-if="blocked === 'store-unreachable'">
+        <strong>{{ t('The template store could not be read.') }}</strong>
+        {{ t('Horizon renders what OAP holds, so it is serving nothing rather than defaults.') }}
+      </template>
+      <template v-else>
+        <strong>{{ t('OAP unreachable.') }}</strong>
+        {{ errorText ?? t('API dependency feed failed — check the BFF and OAP.') }}
+      </template>
     </div>
     <div v-if="metricsPartial" class="banner warn">
       {{ t('Some metrics could not be loaded ({failed} of {total} batches failed) — some endpoints or links may be missing.', { failed: metricsPartial.failedChunks, total: metricsPartial.totalChunks }) }}
@@ -1004,15 +1110,15 @@ function edgeRowCrosshair(rowId: string): number | null {
             <g
               v-if="selectedNodeId === n.id && n.id !== focusedId && !embedded"
               class="ep-expand"
-              :class="{ exhausted: isExhausted(n), loading: isLoadingExpansion(n) }"
+              :class="{ exhausted: isExhausted(n), loading: isLoadingExpansion(n), blocked: expanding && !isLoadingExpansion(n) }"
               :transform="`translate(${NW - 9}, -9)`"
               role="button"
               tabindex="0"
               :aria-label="t('Expand {name} — show its callers and callees', { name: n.name })"
               @pointerdown.stop
-              @click.stop="expandNode(n)"
-              @keydown.enter.prevent="expandNode(n)"
-              @keydown.space.prevent="expandNode(n)"
+              @click.stop="!expanding && expandNode(n)"
+              @keydown.enter.prevent="!expanding && expandNode(n)"
+              @keydown.space.prevent="!expanding && expandNode(n)"
             >
               <circle r="9" cx="9" cy="9" fill="var(--sw-bg-0)" :stroke="hasExpansion(n) || isLoadingExpansion(n) ? 'var(--sw-accent-2)' : 'var(--sw-line-2)'" stroke-width="1" />
               <circle
@@ -1042,7 +1148,20 @@ function edgeRowCrosshair(rowId: string): number | null {
           </g>
         </svg>
 
-          <div v-else-if="isLoading" class="loader">{{ t('loading…') }}</div>
+          <div v-else-if="phase === 'loading'" class="loader">
+            {{ t('Loading dependencies for “{target}”…', { target: selectedEndpoint ?? '' }) }}
+          </div>
+          <!-- A DISABLED layer is an answer, not a failure to reach one:
+               Retry would invite the operator to keep asking for something
+               that cannot change until someone re-enables it. -->
+          <div v-else-if="phase === 'failed' && blocked === 'layer-disabled'" class="loader">
+            {{ t('This layer’s template is disabled.') }}
+            {{ t('An administrator turned it off — re-enable it under Operate → Templates.') }}
+          </div>
+          <div v-else-if="phase === 'failed'" class="loader">
+            {{ t('Could not load the dependency graph.') }}
+            <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
+          </div>
           <div v-else class="loader">
             {{ t('No dependency graph available for this endpoint in the selected time range.') }}
           </div>
@@ -1315,6 +1434,14 @@ function edgeRowCrosshair(rowId: string): number | null {
 }
 .ep-row:last-child { border-bottom: none; }
 .ep-row.on { background: var(--sw-accent-soft); }
+.ep-expand.blocked {
+  opacity: 0.35;
+  cursor: default;
+}
+.ep-pick:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
 .ep-pick {
   display: block;
   width: 100%;
diff --git a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.test.ts b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.test.ts
index 9bd91c03..a227aaa8 100644
--- a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.test.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.test.ts
@@ -23,7 +23,7 @@
  */
 
 import { describe, expect, it, vi, beforeEach } from 'vitest';
-import { defineComponent, h, ref } from 'vue';
+import { defineComponent, h, nextTick, ref } from 'vue';
 import { mount } from '@vue/test-utils';
 import { createPinia, setActivePinia } from 'pinia';
 import type { EndpointDependencyCall, EndpointDependencyNode } from '@/api/client';
@@ -40,9 +40,14 @@ const endpointDependency = vi.fn(async () => ({
 
 vi.mock('@/api/client', () => ({
   bffClient: { layer: { endpointDependency: (...args: unknown[]) => endpointDependency(...(args as [])) } },
+  // The failure path reports through the error center, which classifies by
+  // error type — so the mock has to carry the type, not just the client.
+  BffApiError: class BffApiError extends Error {},
+  describeApiError: (e: unknown) => String(e),
 }));
 
 const { useEndpointDependencyExpansion } = await import('./useEndpointDependencyExpansion');
+const { useErrorCenterStore } = await import('@/controls/errorCenter');
 
 const NODE: EndpointDependencyNode = {
   id: 'ep-1',
@@ -59,20 +64,29 @@ const NODE: EndpointDependencyNode = {
 
 function mountExpansion() {
   let api!: ReturnType<typeof useEndpointDependencyExpansion>;
+  const selectedEndpoint = ref<string | null>('/api/checkout');
+  // The two things an expansion is built ON, so a test can move either and
+  // watch what happens to the expansions hanging off it.
+  const predicateGeneration = ref(0);
+  const baseSnapshotVersion = ref(0);
   const Host = defineComponent({
     setup() {
       api = useEndpointDependencyExpansion({
         layerKey: ref('general'),
         baseNodes: ref<EndpointDependencyNode[]>([]),
         baseCalls: ref<EndpointDependencyCall[]>([]),
-        selectedEndpoint: ref<string | null>('/api/checkout'),
+        selectedEndpoint,
+        predicateGeneration,
+        previewConfig: ref(undefined),
+        baseWindow: ref({ step: 'MINUTE' as const, startMs: 0, endMs: 60_000 }),
+        baseSnapshotVersion,
         onFocusReset: () => {},
       });
       return () => h('div');
     },
   });
   mount(Host);
-  return api;
+  return { api, selectedEndpoint, predicateGeneration, baseSnapshotVersion };
 }
 
 beforeEach(() => {
@@ -82,7 +96,7 @@ beforeEach(() => {
 
 describe('expandNode — the clicked node keeps its service identity', () => {
   it('asks by the node\'s service id, name and normal flag', async () => {
-    const api = mountExpansion();
+    const { api } = mountExpansion();
     await api.expandNode(NODE);
 
     expect(endpointDependency).toHaveBeenCalledTimes(1);
@@ -99,9 +113,195 @@ describe('expandNode — the clicked node keeps its service identity', () => {
   });
 
   it('does not expand a node whose service identity is incomplete', async () => {
-    const api = mountExpansion();
+    const { api } = mountExpansion();
     await api.expandNode({ ...NODE, serviceId: '' });
 
     expect(endpointDependency).not.toHaveBeenCalled();
   });
 });
+
+/**
+ * An expansion is an MQE fan-out on the BFF, so the wait is seconds — long
+ * enough to pick a different endpoint while one is in flight. Nothing disables
+ * the picker meanwhile, so the reply lands after the focus watcher has cleared
+ * state and, unguarded, repopulates the NEW graph with the OLD focus's branch.
+ */
+describe('a reply that outlives its focus', () => {
+  it('is discarded rather than merged into the graph that replaced it', async () => {
+    // The reply's CONTENTS do not matter here — the assertion is whether the
+    // expansion map gained this node's key at all.
+    type Reply = Awaited<ReturnType<typeof endpointDependency>>;
+    let release!: (r: Reply) => void;
+    endpointDependency.mockImplementationOnce(
+      () => new Promise<Reply>((resolve) => { release = resolve; }),
+    );
+
+    const { api, selectedEndpoint } = mountExpansion();
+    const inFlight = api.expandNode(NODE);
+
+    // The operator picks a different endpoint while the request is out.
+    selectedEndpoint.value = '/api/refunds';
+    await nextTick();
+    expect(api.hasExpansion(NODE), 'the focus change did not clear expansions').toBe(false);
+
+    release({
+      layer: 'general',
+      endpointId: null,
+      nodes: [],
+      calls: [],
+      config: { nodeMetrics: [], linkMetrics: [] },
+      reachable: true,
+      generatedAt: 0,
+    });
+    await inFlight;
+
+    expect(
+      api.hasExpansion(NODE),
+      "a reply from the previous focus was merged into the current graph",
+    ).toBe(false);
+  });
+
+  it('still commits a reply that arrives under the focus that asked for it', async () => {
+    const { api } = mountExpansion();
+
+    await api.expandNode(NODE);
+
+    expect(api.hasExpansion(NODE)).toBe(true);
+  });
+});
+
+/**
+ * An expansion that could not be READ is not an answer about dependencies.
+ *
+ * The route replies 200 with an empty body when OAP is unreachable, so the
+ * "no new nodes" branch used to fire and mark the node exhausted — telling the
+ * operator this endpoint has no further callers or callees, which is a claim
+ * about their system made from a failure to read it.
+ */
+describe('an expansion that failed', () => {
+  it('does not mark the node exhausted, and stays clickable', async () => {
+    endpointDependency.mockImplementationOnce(async () => ({
+      layer: 'general', endpointId: null, nodes: [], calls: [],
+      config: { nodeMetrics: [], linkMetrics: [] },
+      reachable: false, generatedAt: 0,
+    }));
+
+    const { api } = mountExpansion();
+    await api.expandNode(NODE);
+
+    expect(api.isExhausted(NODE), 'a failed read was reported as “no further dependencies”').toBe(false);
+    expect(api.hasExpansion(NODE), 'a failed read was merged into the graph').toBe(false);
+    expect(api.expansionFailed.value?.endpoint).toBe(NODE.name);
+
+    // Clickable again: a second attempt really does reach the backend.
+    endpointDependency.mockClear();
+    await api.expandNode(NODE);
+    expect(endpointDependency).toHaveBeenCalledTimes(1);
+  });
+
+  it('reports a transport failure the same way', async () => {
+    endpointDependency.mockImplementationOnce(async () => { throw new Error('network'); });
+    const { api } = mountExpansion();
+
+    await api.expandNode(NODE);
+
+    expect(api.isExhausted(NODE)).toBe(false);
+    expect(api.expansionFailed.value?.endpoint).toBe(NODE.name);
+  });
+
+  it('still reports exhaustion when the read SUCCEEDED and found nothing new', async () => {
+    const { api } = mountExpansion();
+
+    await api.expandNode(NODE);
+
+    // The default mock is reachable:true with no nodes — a real answer.
+    expect(api.isExhausted(NODE)).toBe(true);
+  });
+});
+
+/**
+ * An expansion is drawn ON a base graph, so it only means anything for the base
+ * it was fetched against. Two things end that: a new base arriving, and the
+ * question changing underneath it.
+ */
+describe('what an expansion is built on', () => {
+  it('drops with the base it was drawn on when a NEW base commits', async () => {
+    const { api, baseSnapshotVersion } = mountExpansion();
+    await api.expandNode(NODE);
+    expect(api.hasExpansion(NODE), 'nothing was expanded — the rest proves nothing').toBe(true);
+
+    // A round landed and replaced the base. The expansion describes nodes that
+    // may not be in it, so it goes in the same update rather than being merged
+    // onto a graph that never contained it.
+    baseSnapshotVersion.value += 1;
+    await nextTick();
+
+    expect(api.hasExpansion(NODE)).toBe(false);
+  });
+
+  it('survives a base refresh that FAILED, which commits no new base', async () => {
+    const { api } = mountExpansion();
+    await api.expandNode(NODE);
+
+    // A failed round changes nothing: no new snapshot, so no version bump.
+    await nextTick();
+
+    expect(api.hasExpansion(NODE), 'a failed refresh discarded the operator’s work').toBe(true);
+  });
+
+  it('drops when the QUESTION changes — window, preview or cold stage', async () => {
+    const { api, predicateGeneration } = mountExpansion();
+    await api.expandNode(NODE);
+
+    predicateGeneration.value += 1;
+    await nextTick();
+
+    expect(api.hasExpansion(NODE)).toBe(false);
+  });
+
+  it('forgets that a node was exhausted, so the new graph can be asked again', async () => {
+    const { api, predicateGeneration } = mountExpansion();
+    await api.expandNode(NODE);
+    await api.expandNode(NODE);
+
+    predicateGeneration.value += 1;
+    await nextTick();
+
+    expect(api.isExhausted(NODE), 'a stale “no further dependencies” carried over').toBe(false);
+  });
+});
+
+/**
+ * §十.5 — the three things a failed expansion must do, asserted together.
+ *
+ * The existing case covers two of them; the toast was the one nothing checked,
+ * and it is the only part the operator actually sees.
+ */
+describe('acceptance: a failed expansion', () => {
+  it('does not exhaust, says so on screen, and a second click asks again', async () => {
+    const { api } = mountExpansion();
+    endpointDependency.mockClear();
+    const center = useErrorCenterStore();
+
+    // HTTP 200 with an unreadable body — how an OAP outage reaches this page.
+    endpointDependency.mockResolvedValueOnce({
+      layer: 'general', service: null, endpoint: null, endpointId: null,
+      generatedAt: 0, config: { nodeMetrics: [] }, nodes: [], calls: [], reachable: false,
+    } as never);
+    await api.expandNode(NODE);
+
+    expect(api.isExhausted(NODE), 'a failure was reported as “no further dependencies”').toBe(false);
+    expect(api.isLoadingExpansion(NODE), 'the handle stayed stuck loading').toBe(false);
+    expect(center.toasts, 'nothing on screen said the expansion failed').toHaveLength(1);
+    expect(center.toasts[0]?.scope, 'the operator’s own click went to the background history').toBe(
+      'component',
+    );
+
+    // Clickable again: a failure is not an answer, so asking twice is allowed.
+    const before = endpointDependency.mock.calls.length;
+    await api.expandNode(NODE);
+    expect(endpointDependency.mock.calls.length, 'a retry produced no second request').toBe(
+      before + 1,
+    );
+  });
+});
diff --git a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.ts b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.ts
index 463deacf..9ea40646 100644
--- a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyExpansion.ts
@@ -40,7 +40,8 @@ import type {
   EndpointDependencyResponse,
 } from '@/api/client';
 import { bffClient } from '@/api/client';
-import { useTimeRangeStore } from '@/controls/timeRange';
+import { accepts, GraphUnavailableError } from '@/layer/graphQuery';
+import { reportActionFailure } from '@/controls/errorCenter';
 import { serviceRef } from '@/utils/serviceRef';
 
 interface ExpansionOptions {
@@ -48,6 +49,32 @@ interface ExpansionOptions {
   baseNodes: Ref<EndpointDependencyNode[]>;
   baseCalls: Ref<EndpointDependencyCall[]>;
   selectedEndpoint: Ref<string | null>;
+  /**
+   * The question the BASE graph is answering.
+   *
+   * An expansion is drawn on top of a base, so it is only meaningful for the
+   * base it was fetched against. When the question changes — a different
+   * endpoint, window, preview or stage — the expansions are answers to a
+   * question nobody is asking any more.
+   */
+  predicateGeneration: Ref<number>;
+  /** The unpublished draft the BASE graph renders against, in admin preview.
+   *  An expansion must be resolved against the same one. */
+  previewConfig: Ref<string | undefined> | { value: string | undefined };
+  /** The window the base graph on screen was read with. */
+  baseWindow: Ref<{ step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number }> | {
+    value: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number };
+  };
+  /**
+   * Which base snapshot is on screen, as a version that advances whenever a
+   * new one is committed.
+   *
+   * A base REFRESH is not a new question, but it is a new answer: the
+   * expansions hanging off the previous one describe nodes that may no longer
+   * be there, so they are dropped as the new base commits rather than merged
+   * into it. A refresh that FAILED commits nothing, and the expansions stay.
+   */
+  baseSnapshotVersion: Ref<number>;
   /** Cascade-clear the view's per-graph state when the focus endpoint changes. */
   onFocusReset: () => void;
 }
@@ -59,14 +86,41 @@ function callHasMetrics(c: EndpointDependencyCall): boolean {
 }
 
 export function useEndpointDependencyExpansion(opts: ExpansionOptions) {
-  const { layerKey, baseNodes, baseCalls, selectedEndpoint, onFocusReset } = opts;
-  const timeRange = useTimeRangeStore();
+  const {
+    layerKey,
+    baseNodes,
+    baseCalls,
+    selectedEndpoint,
+    predicateGeneration,
+    previewConfig,
+    baseWindow,
+    baseSnapshotVersion,
+    onFocusReset,
+  } = opts;
 
   // Keyed by node id so a repeat click is a no-op; a click that surfaces
   // nothing new marks the node exhausted, fading the handle.
   const expansions = ref<Map<string, EndpointDependencyResponse>>(new Map());
   const expansionsLoading = ref<Set<string>>(new Set());
   const exhausted = ref<Set<string>>(new Set());
+  /**
+   * Which focus the state below belongs to.
+   *
+   * Plain, not a ref: nothing renders it, and it must be read at exactly the
+   * moment a request is made and compared at exactly the moment it returns —
+   * reactivity would only invite a stale read.
+   */
+  let epoch = 0;
+  /** The expansion now out, so leaving the screen can stop it. */
+  let inFlight: AbortController | null = null;
+  /**
+   * The last expansion that could not be read.
+   *
+   * A signal rather than a message: the view owns how it is shown, and the
+   * timestamp makes a repeat failure on the same endpoint a NEW event rather
+   * than a no-op the view would ignore.
+   */
+  const expansionFailed = ref<{ at: number; endpoint: string } | null>(null);
   function hasExpansion(node: EndpointDependencyNode): boolean {
     return expansions.value.has(node.id);
   }
@@ -100,35 +154,101 @@ export function useEndpointDependencyExpansion(opts: ExpansionOptions) {
     const loading = new Set(expansionsLoading.value);
     loading.add(key);
     expansionsLoading.value = loading;
+    // The focus this request belongs to. An endpoint-dependency build is an
+    // MQE fan-out on the BFF, so the wait is seconds — long enough to pick a
+    // different endpoint in the meantime. Without this, that reply lands
+    // AFTER the focus watcher has cleared, and repopulates the new graph with
+    // the old focus's branches.
+    const mine = epoch;
+    inFlight?.abort();
+    const controller = new AbortController();
+    inFlight = controller;
     try {
       const before = new Set(nodes.value.map((n) => n.id));
       const resp = await bffClient.layer.endpointDependency(
         layerKey.value,
         owner,
         node.name,
-        { step: timeRange.step, startMs: timeRange.range.startMs, endMs: timeRange.range.endMs },
+        // The window the BASE on screen was read with, not whatever the clock
+        // says now. An expansion is merged INTO that base, so asking about a
+        // different window would put two windows' answers in one graph — and
+        // the base can legitimately be older than the clock, since a failed
+        // round leaves the previous one in place.
+        { step: baseWindow.value.step, startMs: baseWindow.value.startMs, endMs: baseWindow.value.endMs },
+        // The draft the base graph is being rendered against. Omitting it made
+        // an expansion resolve against the PUBLISHED template while the rest of
+        // the graph showed the operator's unpublished edit — one picture built
+        // from two different configurations, with nothing on screen to say so.
+        previewConfig.value,
+        controller.signal,
       );
+      if (mine !== epoch) return;
+      // A response that could not be read is NOT an answer about dependencies.
+      // The route replies 200 with an empty body when OAP is unreachable, and
+      // treating that as "no new nodes" marked the branch EXHAUSTED and told
+      // the operator this endpoint has no further callers or callees — a claim
+      // about their system, made from a failure to read it. Nothing is written,
+      // nothing is marked, the graph is untouched, and the handle stays live so
+      // the click can simply be repeated.
+      if (!accepts(resp)) {
+        expansionFailed.value = { at: Date.now(), endpoint: node.name };
+        // The operator clicked, so they get an answer where they are looking.
+        // The refresh history is for what the timer did unasked; this is not
+        // that, and burying a click's outcome there would lose it.
+        reportActionFailure(new GraphUnavailableError(resp), 'Endpoint dependency', 'expanding an endpoint');
+        return;
+      }
       const next = new Map(expansions.value);
       next.set(key, resp);
       expansions.value = next;
+      // Only now, on a read that genuinely succeeded, does "no new nodes" mean
+      // there are none.
       if (!resp.nodes.some((n) => !before.has(n.id))) {
         const e = new Set(exhausted.value);
         e.add(key);
         exhausted.value = e;
         flashNoDep(node.name);
       }
-    } catch {
-      // Soft-fail — the operator can click again to retry.
+    } catch (err) {
+      // Transport failure — same rule: report it, change nothing, stay
+      // clickable. Guarded on the epoch so a reply that outlived its focus
+      // cannot raise an error against the graph that replaced it.
+      if (mine === epoch) {
+        expansionFailed.value = { at: Date.now(), endpoint: node.name };
+        reportActionFailure(err, 'Endpoint dependency', 'expanding an endpoint');
+      }
     } finally {
-      const done = new Set(expansionsLoading.value);
-      done.delete(key);
-      expansionsLoading.value = done;
+      if (inFlight === controller) inFlight = null;
+      // Only if this request is still the current one. `clearExpansions` has
+      // already emptied the set for a superseded request, and the operator may
+      // have re-expanded the SAME node since — deleting its key here would take
+      // the spinner off a request that is still out.
+      if (mine === epoch) {
+        const done = new Set(expansionsLoading.value);
+        done.delete(key);
+        expansionsLoading.value = done;
+      }
     }
   }
-  watch(selectedEndpoint, () => {
+  /** Everything an expansion is built on. Any of it moving invalidates them. */
+  function clearExpansions(): void {
+    epoch += 1;
     expansions.value = new Map();
     expansionsLoading.value = new Set();
     exhausted.value = new Set();
+  }
+  // A NEW BASE, atomically: the expansions go in the same update that puts the
+  // new base on screen, rather than being merged onto it and then noticed as
+  // wrong. A failed refresh does not advance the version, so nothing is lost
+  // to a round that could not be read.
+  watch(baseSnapshotVersion, () => clearExpansions());
+  // A different QUESTION — endpoint, window, preview, cold stage. Same rule,
+  // and it fires before the new base has even arrived.
+  watch(predicateGeneration, () => clearExpansions());
+  watch(selectedEndpoint, () => {
+    // Everything in flight belongs to the focus being left. The epoch bump
+    // inside is what stops it landing here after the clear — see `expand`.
+    clearExpansions();
     noDepFlash.value = null;
     // Endpoint ids are stable across focuses, so without this cascade-clear a
     // selection under the old focus keeps the detail sidebar open on the new graph.
@@ -173,9 +293,31 @@ export function useEndpointDependencyExpansion(opts: ExpansionOptions) {
 
   onBeforeUnmount(() => {
     if (noDepFlashTimer) clearTimeout(noDepFlashTimer);
+    // Navigating away while an expansion is out. The request outlives this
+    // screen — nothing cancels it — so without advancing the epoch its reply
+    // still passes the guard and raises a toast about an endpoint graph the
+    // operator left, on whatever page they went to. Bumping it discards the
+    // reply the same way a focus change does. It also aborts the request, so
+    // an abandoned expansion stops costing OAP work.
+    inFlight?.abort();
+    epoch += 1;
   });
 
+  /**
+   * Is an expansion out right now?
+   *
+   * While one is, the graph accepts no other query: the endpoint picker and the
+   * other expand handles are disabled. That closes the stale-reply race at its
+   * SOURCE — there is no "switch to B while A is loading" if B cannot be
+   * clicked — and it makes a wait that used to be silent visible. The epoch
+   * guard stays as defence in depth, for the paths a disabled control cannot
+   * close: a route change, a time-range change, a programmatic refresh.
+   */
+  const expanding = computed(() => expansionsLoading.value.size > 0);
+
   return {
+    expanding,
+    expansionFailed,
     nodes,
     calls,
     noDepFlash,
diff --git a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.test.ts b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.test.ts
new file mode 100644
index 00000000..1172194e
--- /dev/null
+++ b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.test.ts
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * A column decides WHICH endpoints it shows from live data, and WHERE it puts
+ * them from what it showed last.
+ *
+ * Both halves matter and they pull in opposite directions. Ranking rows by the
+ * live metric meant the column re-sorted itself every refresh, so an operator
+ * watching one endpoint had to find it again after every tick. Freezing the
+ * whole column instead would hide an endpoint that had just become the busiest
+ * thing on the screen — which is the one an operator most needs to see.
+ */
+
+import { describe, expect, it } from 'vitest';
+import { ref } from 'vue';
+import type { EndpointDependencyCall, EndpointDependencyNode } from '@/api/client';
+import { useEndpointDependencyLayout } from './useEndpointDependencyLayout';
+
+const node = (id: string, cpm: number): EndpointDependencyNode =>
+  ({ id, name: id, serviceId: 's', serviceName: 'svc', type: null, isReal: true, cpm }) as
+    unknown as EndpointDependencyNode;
+
+/** Focus plus N callees, so every extra node lands in one column (L+1). */
+function graph(nodes: Array<[string, number]>): {
+  nodes: EndpointDependencyNode[];
+  calls: EndpointDependencyCall[];
+} {
+  return {
+    nodes: [node('focus', 100), ...nodes.map(([id, cpm]) => node(id, cpm))],
+    calls: nodes.map(([id]) => ({ source: 'focus', target: id }) as EndpointDependencyCall),
+  };
+}
+
+function layout(initial: Array<[string, number]>) {
+  const g = graph(initial);
+  const nodes = ref(g.nodes);
+  const calls = ref(g.calls);
+  const l = useEndpointDependencyLayout({
+    nodes,
+    calls,
+    focusedId: ref('focus'),
+    centerDef: ref(null),
+    nodeVal: () => null,
+    dragAnchors: ref(new Map()),
+    t: (k) => k,
+  });
+  /** A refresh: the same graph comes back with different numbers on it. */
+  const refresh = (next: Array<[string, number]>): void => {
+    const g2 = graph(next);
+    nodes.value = g2.nodes;
+    calls.value = g2.calls;
+  };
+  const callees = (): string[] =>
+    l.layerColumns.value.find((c) => c.index === 1)?.visible.map((n) => n.id) ?? [];
+  return { callees, refresh };
+}
+
+describe('a refresh does not reshuffle the column', () => {
+  it('keeps every row where it was when only the numbers moved', () => {
+    const { callees, refresh } = layout([
+      ['a', 90],
+      ['b', 50],
+      ['c', 10],
+    ]);
+    const before = callees();
+    expect(before, 'nothing was laid out — the rest would prove nothing').toEqual(['a', 'b', 'c']);
+
+    // The order the metric WOULD now imply is the exact reverse.
+    refresh([
+      ['a', 10],
+      ['b', 50],
+      ['c', 90],
+    ]);
+
+    expect(callees(), 'the column re-sorted itself under the operator').toEqual(before);
+  });
+
+  it('gives a newly arrived endpoint the next free row rather than the top', () => {
+    const { callees, refresh } = layout([
+      ['a', 90],
+      ['b', 50],
+    ]);
+    // Read first: a row is only "already on screen" once it has been drawn,
+    // and the column is a lazy computed.
+    expect(callees()).toEqual(['a', 'b']);
+
+    refresh([
+      ['a', 90],
+      ['b', 50],
+      ['d', 1000],
+    ]);
+
+    expect(callees(), 'an arrival displaced the rows already on screen').toEqual(['a', 'b', 'd']);
+  });
+
+  it('closes the gap when an endpoint leaves, and holds the rest', () => {
+    const { callees, refresh } = layout([
+      ['a', 90],
+      ['b', 50],
+      ['c', 10],
+    ]);
+    expect(callees()).toEqual(['a', 'b', 'c']);
+
+    refresh([
+      ['a', 90],
+      ['c', 10],
+    ]);
+
+    expect(callees()).toEqual(['a', 'c']);
+  });
+});
+
+describe('selection stays live', () => {
+  it('admits an endpoint that has become busy enough to make the cut', () => {
+    // Nine callees for eight rows: the quietest is left out. Then it becomes
+    // the busiest — and being new to the visible set, it takes the free row
+    // rather than the top, which is the point of the split.
+    const nine: Array<[string, number]> = Array.from({ length: 9 }, (_, i) => [
+      `n${i}`,
+      100 - i,
+    ]);
+    const { callees, refresh } = layout(nine);
+    expect(callees(), 'the quietest should have been cut').not.toContain('n8');
+
+    refresh(nine.map(([id]) => [id, id === 'n8' ? 10_000 : 1] as [string, number]));
+
+    expect(callees(), 'a newly busiest endpoint stayed hidden').toContain('n8');
+  });
+});
diff --git a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.ts b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.ts
index 9313e1d3..f7de4914 100644
--- a/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useEndpointDependencyLayout.ts
@@ -80,14 +80,23 @@ interface LayoutOptions {
   centerDef: Ref<TopologyMetricDef | null>;
   /** Resolve a node's value for a metric def (null when absent). */
   nodeVal: (n: EndpointDependencyNode, def: TopologyMetricDef | null) => number | null;
-  /** Manual drag offsets layered on the BFS positions. */
-  dragOffsets: Ref<Map<string, { dx: number; dy: number }>>;
+  /**
+   * Where the operator PUT each node, in absolute layout coordinates.
+   *
+   * Absolute rather than an offset from the column position, because the
+   * column positions move: a node arriving or leaving re-packs the rows, and
+   * re-applying the same offset to a moved base carries a placed node along
+   * with the re-pack — so a node set aside drifts on a refresh that had
+   * nothing to do with it. What the operator chose is a POSITION, and that is
+   * what is stored.
+   */
+  dragAnchors: Ref<Map<string, { x: number; y: number }>>;
   /** i18n translator — column labels (`L-1 · Callers`, …). */
   t: (key: string, named?: Record<string, unknown>) => string;
 }
 
 export function useEndpointDependencyLayout(opts: LayoutOptions) {
-  const { nodes, calls, focusedId, centerDef, nodeVal, dragOffsets, t } = opts;
+  const { nodes, calls, focusedId, centerDef, nodeVal, dragAnchors, t } = opts;
 
   const layoutNodes = computed<LayoutNode[]>(() => {
     const all = nodes.value;
@@ -144,12 +153,29 @@ export function useEndpointDependencyLayout(opts: LayoutOptions) {
     return all.map((n) => ({ ...n, layerIdx: layerOf.get(n.id)! }));
   });
 
+  /**
+   * Which ROW each endpoint has been given, per column.
+   *
+   * The column is SELECTED by the centre metric and always has been — the
+   * busiest endpoints are the ones worth a row. But it was also ORDERED by
+   * that metric, and the metric moves on every refresh, so the rows reshuffled
+   * every cycle: an operator tracking one endpoint had to find it again after
+   * each tick, and any position they had set was attached to a row that had
+   * moved under it.
+   *
+   * So selection stays live and placement does not. A node that keeps its
+   * place in the visible set keeps its row; a node that arrives takes the
+   * lowest free one. Nothing about which nodes are shown changes.
+   */
+  const rowOf = new Map<number, string[]>();
+
   const layerColumns = computed<LayerColumn[]>(() => {
     const byLayer = new Map<number, LayoutNode[]>();
     for (const n of layoutNodes.value) {
       if (!byLayer.has(n.layerIdx)) byLayer.set(n.layerIdx, []);
       byLayer.get(n.layerIdx)!.push(n);
     }
+    for (const stale of [...rowOf.keys()]) if (!byLayer.has(stale)) rowOf.delete(stale);
     const indices = [...byLayer.keys()].sort((a, b) => a - b);
     return indices.map((i) => {
       // Sort by the operator-configured `center` metric (typically RPM).
@@ -160,8 +186,14 @@ export function useEndpointDependencyLayout(opts: LayoutOptions) {
         const vb = nodeVal(b, centerDef.value) ?? b.cpm ?? 0;
         return vb - va;
       });
-      const visible = list.slice(0, NODES_PER_LAYER);
-      const hidden = list.length - visible.length;
+      const selected = list.slice(0, NODES_PER_LAYER);
+      const hidden = list.length - selected.length;
+      const held = (rowOf.get(i) ?? []).filter((id) => selected.some((n) => n.id === id));
+      const visible = [
+        ...held.map((id) => selected.find((n) => n.id === id)!),
+        ...selected.filter((n) => !held.includes(n.id)),
+      ];
+      rowOf.set(i, visible.map((n) => n.id));
       let label: string;
       // Focus = L0; layers to the LEFT (negative index) are the focus's
       // callers, to the RIGHT (positive) its callees. Labelled `Callers`
@@ -197,13 +229,14 @@ export function useEndpointDependencyLayout(opts: LayoutOptions) {
     return map;
   });
 
-  // Drag offsets layer on the BFS positions so edges (which read displayPos) follow.
+  // Placements override the column position; edges read `displayPos`, so they
+  // follow.
   const displayPos = computed<Map<string, Pos>>(() => {
-    if (dragOffsets.value.size === 0) return nodePos.value;
+    if (dragAnchors.value.size === 0) return nodePos.value;
     const out = new Map<string, Pos>();
     for (const [id, p] of nodePos.value) {
-      const off = dragOffsets.value.get(id);
-      out.set(id, off ? { ...p, x: p.x + off.dx, y: p.y + off.dy } : p);
+      const at = dragAnchors.value.get(id);
+      out.set(id, at ? { ...p, x: at.x, y: at.y } : p);
     }
     return out;
   });
diff --git a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
index 404cb0f6..b45d3edf 100644
--- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
@@ -23,8 +23,23 @@
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
-import type { EndpointDependencyResponse } from '@skywalking-horizon-ui/api-client';
-import { useTimeRangeStore, stepForMinutes } from '../../controls/timeRange';
+import type { EndpointDependencyCall, EndpointDependencyNode, EndpointDependencyResponse } from '@skywalking-horizon-ui/api-client';
+import { useAutoRefreshSubscribe } from '@/controls/useAutoRefreshSubscribe';
+import { useRefreshErrorReport } from '@/controls/errorCenter';
+import {
+  windowOf,
+  useRoundWindow,
+  anchorForMount,
+  predicateKey,
+  predicateService,
+  fetchDrawable,
+  useGraphState,
+  useTimeIdentity,
+  useTriggeredRefetch,
+  type GraphPredicate,
+} from '@/layer/graphQuery';
+import { useColdStageStore } from '@/controls/coldStage';
+import { stepForMinutes } from '../../controls/timeRange';
 import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import { bffClient } from '@/api/client';
 import type { ServiceRef } from '@/utils/serviceRef';
@@ -42,63 +57,171 @@ export function useLayerEndpointDependency(
   replayData?: Ref<EndpointDependencyResponse | null>,
 ) {
   const replay = computed(() => !!replayData?.value);
-  const ownsWindow = (windowMinutes?.value ?? 0) > 0;
+  // A COMPUTED, not a one-time read. It decides which clock the whole query
+  // follows — a frozen local window or the global picker — and the caller
+  // supplies it as a ref that can change (an embedded block derives it from
+  // its own props). Snapshotting it at setup left the query following
+  // whichever source happened to be true on the first render.
+  const ownsWindow = computed(() => (windowMinutes?.value ?? 0) > 0);
   // Preview-only: forward the draft `endpointDependency` block.
   const previewCfg = usePreviewLayerBlock(layerKey, 'endpointDependency');
-  const timeRange = useTimeRangeStore();
+  const cold = useColdStageStore();
+  const roundWindow = useRoundWindow();
   const rangeKey = computed(() => {
-    if (ownsWindow) {
+    if (ownsWindow.value) {
       const min = windowMinutes!.value ?? 0;
       const endMs = Date.now();
       return { step: stepForMinutes(min), startMs: endMs - min * 60_000, endMs };
     }
-    return {
-      step: timeRange.step,
-      startMs: timeRange.range.startMs,
-      endMs: timeRange.range.endMs,
-    };
+    // The ROUND's window while a round is out, so every screen in one round
+    // asks about the same one; the topbar's own otherwise.
+    return roundWindow.value;
   });
+  /**
+   * Lifted out of the query so the ticker guard uses the SAME gate. A manual
+   * `refetch()` bypasses `enabled`, so a subscription that re-derived the
+   * condition could drift from it and fetch where the query would not.
+   */
+  const queryEnabled = computed(
+    () =>
+      layerKey.value.length > 0 &&
+      !!service.value &&
+      service.value.normal !== null &&
+      service.value.normal !== undefined &&
+      !!endpoint.value &&
+      !replay.value,
+  );
+  const timeIdentity = useTimeIdentity(ownsWindow, rangeKey);
+  const predicate = computed<GraphPredicate>(() => ({
+    layer: layerKey.value,
+    service: predicateService(service.value),
+    endpoint: endpoint.value ?? null,
+    time: timeIdentity.value,
+    preview: previewCfg.value ?? null,
+    cold: cold.enabled,
+  }));
+  const predicateKeyRef = computed(() => predicateKey(predicate.value));
+  // BEFORE the query below: it fetches on mount, reading the window as it
+  // stands then. Anchoring afterwards would leave that first request asking
+  // about whenever the window was last moved.
+  anchorForMount(ownsWindow);
   const q = useQuery({
-    queryKey: ['layer-endpoint-dependency', layerKey, service, endpoint, rangeKey, previewCfg],
-    queryFn: () =>
-      bffClient.layer.endpointDependency(
-        layerKey.value,
-        service.value!,
-        endpoint.value ?? '',
+    queryKey: ['layer-endpoint-dependency', predicateKeyRef],
+    queryFn: ({ signal }) =>
+      fetchDrawable(
+        () =>
+          bffClient.layer.endpointDependency(
+            layerKey.value,
+            service.value!,
+            endpoint.value ?? '',
+            rangeKey.value,
+            previewCfg.value,
+            signal,
+          ),
         rangeKey.value,
-        previewCfg.value,
       ),
     // The focus endpoint's own metrics are name-scoped MQE, so this read needs
     // the roster row's normal flag as well as the pair — it waits for the row
     // rather than sending a request the BFF must refuse.
-    enabled: computed(
-      () =>
-        layerKey.value.length > 0 &&
-        !!service.value &&
-        service.value.normal !== null &&
-        service.value.normal !== undefined &&
-        !!endpoint.value &&
-        !replay.value,
-    ),
-    staleTime: 30_000,
+    enabled: queryEnabled,
+    staleTime: 0,
+    // The ROUND decides when this fetches. Refetching on window focus as well
+    // would fire outside any round: unattributed, uncounted by the countdown,
+    // and capable of landing on top of a round already out.
+    refetchOnWindowFocus: false,
+    // Stated rather than inherited: a remount must ask again, whatever the
+    // freshness rule above happens to be. The window it asks about is the one
+    // `triggeredRefetch` re-anchors on the way in, not the one this cache
+    // entry was built with.
+    refetchOnMount: 'always',
   });
-  // No ticker subscription: this query is keyed on `rangeKey`, and a rolling
-  // preset's window advances with the ticker, so each tick already re-keys the
-  // query and vue-query fetches the new window. Subscribing as well would fire
-  // two requests per tick for the same data. A frozen window (embedded/replay,
-  // or a pinned custom range) does not re-key — and must not refetch anyway.
+  // The ticker is now the refresh mechanism: the key no longer moves. Guarded,
+  // because `refetch()` bypasses `enabled` — an unguarded subscription would
+  // make replay blocks and frozen captures contact OAP.
+  const triggeredRefetch = useTriggeredRefetch(() => q.refetch({ cancelRefetch: false }), ownsWindow);
+  // Only what the TIMER could not read reaches the history — a first load
+  // that fails already says so on the canvas.
+  useRefreshErrorReport({ owner: 'Endpoint dependency', action: 'reading the endpoint dependency graph', error: q.error });
+  // The component's own field: this screen takes part only when its query
+  // would, and never when it owns a frozen window. The promise is RETURNED so
+  // the round counts its next interval from when this settles.
+  useAutoRefreshSubscribe(
+    () => triggeredRefetch(),
+    computed(() => queryEnabled.value && !ownsWindow.value),
+    // Named so a capped round can cancel this query rather than only stop
+    // waiting on it. Resolved on demand, because the key moves with the
+    // predicate and the round cancels the one that is actually out.
+    () => ['layer-endpoint-dependency', predicateKeyRef.value],
+  );
 
   // Replay renders straight from the captured payload — NOT through the shared
-  // query cache. Seeding initialData under the live query key would let a chat
-  // snapshot serve a live view during staleTime (and vice-versa).
-  const data = computed(() => (replay.value ? (replayData?.value ?? null) : (q.data.value ?? null)));
+  // query cache. Seeding initialData under the live query key would put a
+  // frozen capture into the entry a live view reads from, so the two would
+  // answer for each other — a snapshot shown as current, or a capture quietly
+  // replaced by data taken after it.
+  // The last GOOD response comes from the query cache, so it survives both a
+  // failed round and a remount; `latestAttempt` is whatever came back last and
+  // drives the banners. `phase` is derived from both so a view cannot read half
+  // its picture from one and half from the other.
+  const { acceptedSnapshot, latestAttempt, phase, predicateGeneration } =
+    useGraphState<EndpointDependencyResponse>({
+      data: q.data,
+      error: q.error,
+      isFetching: q.isFetching,
+      // How the gate below tells a fresh answer from one the cache
+      // still had for a predicate visited earlier.
+      dataUpdatedAt: q.dataUpdatedAt,
+      predicateKey: predicateKeyRef,
+      enabled: queryEnabled,
+      replay,
+      ...(replayData ? { replayData } : {}),
+    });
+  /**
+   * The window the graph ON SCREEN was read with, taken from the response
+   * itself.
+   *
+   * Not tracked in a ref: on a remount the accepted snapshot comes from the
+   * cache and can be minutes old, while a ref would have just been initialised
+   * from the freshly anchored window — so an expansion started before the
+   * refetch landed asked about the new window and merged it into the old
+   * graph. Reading it off the response cannot drift, because it IS the
+   * response's own. Falls back to the live window only when nothing was
+   * recorded, which is the first paint before any read has landed.
+   */
+  const acceptedWindow = computed(() => {
+    const remembered = windowOf(acceptedSnapshot.value);
+    return (remembered ?? rangeKey.value) as {
+      step: 'MINUTE' | 'HOUR' | 'DAY';
+      startMs: number;
+      endMs: number;
+    };
+  });
+
   return {
-    data,
-    nodes: computed(() => data.value?.nodes ?? []),
-    calls: computed(() => data.value?.calls ?? []),
+    data: latestAttempt,
+    latestAttempt,
+    phase,
+    drawable: acceptedSnapshot,
+    previewConfig: previewCfg,
+    // The window the graph ON SCREEN was read with — not the live one.
+    //
+    // `rangeKey` is what the NEXT request will ask about, and the two part
+    // company the moment a refresh fails: the accepted snapshot stays at W1
+    // while the clock has moved to W2. An expansion is merged INTO that
+    // snapshot, so taking the live window put a W2 branch inside a W1 graph —
+    // silently, permanently, and with nothing on screen to say the picture was
+    // assembled from two different windows.
+    baseWindow: acceptedWindow,
+    acceptedSnapshot,
+    predicateGeneration,
+    predicate,
+    predicateKey: predicateKeyRef,
+    nodes: computed<EndpointDependencyNode[]>(() => acceptedSnapshot.value?.nodes ?? []),
+    calls: computed<EndpointDependencyCall[]>(() => acceptedSnapshot.value?.calls ?? []),
+    initialPending: computed(() => phase.value === 'loading'),
     isLoading: q.isLoading,
     isFetching: q.isFetching,
     error: q.error,
-    refetch: q.refetch,
+    refetch: triggeredRefetch,
   };
 }
diff --git a/apps/ui/src/layer/graphQuery.test.ts b/apps/ui/src/layer/graphQuery.test.ts
new file mode 100644
index 00000000..826f9b2c
--- /dev/null
+++ b/apps/ui/src/layer/graphQuery.test.ts
@@ -0,0 +1,498 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * The two decisions the graph screens share.
+ *
+ * These are worth testing directly because both failure modes are invisible on
+ * a healthy system: a key that carries the clock only misbehaves once a tick
+ * lands, and a response that must not be drawn only arrives when the backend is
+ * unwell — which is exactly when an operator is looking at the screen.
+ */
+
+import { describe, expect, it, beforeEach } from 'vitest';
+import { computed, ref } from 'vue';
+import { createPinia, setActivePinia } from 'pinia';
+import { accepts, fetchDrawable, GraphUnavailableError, predicateKey, predicateService, useGraphState, useTimeIdentity, useTriggeredRefetch, anchorForMount, useRoundWindow } from './graphQuery';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import { useAutoRefreshStore } from '@/controls/autoRefresh';
+
+interface Resp {
+  reachable?: boolean;
+  nodes: string[];
+  /** Both are real response fields the accept-gate must NOT reject on. */
+  tooLarge?: { nodes: number; edges: number };
+  metricsPartial?: { failedChunks: number; totalChunks: number };
+}
+const good = (n: string[]): Resp => ({ reachable: true, nodes: n });
+const failed = (): Resp => ({ reachable: false, nodes: [] });
+
+beforeEach(() => setActivePinia(createPinia()));
+
+describe('accepts — which responses are worth drawing', () => {
+  it('takes a populated graph', () => {
+    expect(accepts(good(['a']))).toBe(true);
+  });
+
+  // An empty graph is a FACT — this layer genuinely has no services with data —
+  // and replacing it with a stale picture would be a lie.
+  it('takes a genuinely empty graph', () => {
+    expect(accepts(good([]))).toBe(true);
+  });
+
+  // The one that matters: the BFF never throws, so an OAP failure arrives as a
+  // 200 with an empty body. Drawn, it erases the graph the operator was reading.
+  it('refuses an unreachable response', () => {
+    expect(accepts({ reachable: false, nodes: [] })).toBe(false);
+  });
+
+  it('refuses nothing at all', () => {
+    expect(accepts(null)).toBe(false);
+    expect(accepts(undefined)).toBe(false);
+  });
+
+  // `tooLarge` is deliberately `reachable: true` with no nodes, and
+  // `metricsPartial` is a good graph whose metric chunks partly failed. Both
+  // must reach the screen — they render their own explanation.
+  it('takes the deliberate empty and partial shapes', () => {
+    expect(accepts({ reachable: true, nodes: [], tooLarge: { nodes: 9e5, edges: 9e6 } })).toBe(true);
+    expect(accepts({ reachable: true, nodes: ['a'], metricsPartial: { failedChunks: 2, totalChunks: 9 } })).toBe(true);
+  });
+});
+
+describe('the graph state machine', () => {
+  const state = (o: {
+    data?: Resp; error?: Error | null; fetching?: boolean; enabled?: boolean; replay?: boolean;
+    replayData?: Resp | null; predicateKey?: string; fetchedAt?: number;
+  }) => useGraphState<Resp>({
+    data: ref(o.data),
+    error: ref(o.error ?? null),
+    isFetching: ref(o.fetching ?? false),
+    // Fetched in the future by default, so the freshness gate is satisfied and
+    // these cases test what they are named for rather than the gate.
+    dataUpdatedAt: ref(o.fetchedAt ?? Date.now() + 60_000),
+    predicateKey: computed(() => o.predicateKey ?? 'p1'),
+    enabled: computed(() => o.enabled ?? true),
+    replay: computed(() => o.replay ?? false),
+    ...(o.replayData !== undefined ? { replayData: ref(o.replayData) } : {}),
+  });
+
+  // The failure the whole design exists for: the BFF answers 200 with an empty
+  // body when OAP is unreachable, so the "success" path must refuse it.
+  it('turns an unreachable response into a failure the cache can survive', async () => {
+    await expect(fetchDrawable(async () => failed())).rejects.toBeInstanceOf(GraphUnavailableError);
+    await expect(fetchDrawable(async () => good(['a']))).resolves.toEqual(good(['a']));
+  });
+
+  it('keeps drawing the cached graph while the latest attempt failed', () => {
+    const s = state({ data: good(['a']), error: new GraphUnavailableError(failed()) });
+    expect(s.acceptedSnapshot.value?.nodes).toEqual(['a']);
+    // The banner needs the FAILED response, not the drawn one.
+    expect(s.latestAttempt.value?.reachable).toBe(false);
+    expect(s.phase.value).toBe('ready');
+  });
+
+  // The state that used to be indistinguishable from loading: an empty
+  // drawable read as "still waiting", so a first failure showed the loading
+  // line for ever.
+  it('reports a FIRST failure as failed, not as loading', () => {
+    expect(state({ error: new GraphUnavailableError(failed()) }).phase.value).toBe('failed');
+    expect(state({}).phase.value).toBe('loading');
+  });
+
+  it('separates refreshing from loading', () => {
+    expect(state({ data: good(['a']), fetching: true }).phase.value).toBe('refreshing');
+    expect(state({ fetching: true }).phase.value).toBe('loading');
+  });
+
+  it('is ready from the first frame in replay, whose query never runs', () => {
+    const s = state({ replay: true, replayData: good(['x']), enabled: false });
+    expect(s.phase.value).toBe('ready');
+    expect(s.acceptedSnapshot.value?.nodes).toEqual(['x']);
+  });
+
+  it('is disabled when nothing is coming', () => {
+    expect(state({ enabled: false }).phase.value).toBe('disabled');
+  });
+
+  it('takes it as soon as it has been fetched for THIS question', () => {
+    expect(state({ data: good(['fresh']) }).acceptedSnapshot.value?.nodes).toEqual(['fresh']);
+  });
+
+  // The other side of the same rule, and the one the gate above nearly broke:
+  // a REMOUNT of the same question must keep showing its last good graph. That
+  // is the entire reason the snapshot lives in the query cache rather than in a
+  // ref that dies with the component — losing it on remount would put an empty
+  // canvas in front of an operator who only switched tabs.
+  it('keeps a cached graph on remount, even one fetched long before mounting', () => {
+    const s = state({ data: good(['a', 'b']), fetchedAt: Date.now() - 30_000 });
+
+    expect(s.acceptedSnapshot.value?.nodes, 'a remount lost the last good graph').toEqual(['a', 'b']);
+    expect(s.phase.value).toBe('ready');
+  });
+
+  it('and keeps it on remount even when the read that followed the mount FAILED', () => {
+    const s = state({
+      data: good(['a', 'b']),
+      fetchedAt: Date.now() - 30_000,
+      error: new GraphUnavailableError({ reachable: false, blocked: 'store-unreachable' }),
+    });
+
+    expect(s.acceptedSnapshot.value?.nodes, 'a failed post-mount read erased the graph').toEqual([
+      'a',
+      'b',
+    ]);
+    expect(s.phase.value).toBe('ready');
+  });
+
+  // And a predicate CHANGE still refuses the stale answer — the two rules do
+  // not collapse into each other.
+  it('still refuses a stale answer after the question changes', async () => {
+    const key = ref('p1');
+    const data = ref<Resp | undefined>(good(['old']));
+    const fetchedAt = ref(Date.now() - 30_000);
+    const s = useGraphState<Resp>({
+      data,
+      error: ref(null),
+      isFetching: ref(false),
+      dataUpdatedAt: fetchedAt,
+      predicateKey: computed(() => key.value),
+      enabled: computed(() => true),
+      replay: computed(() => false),
+    });
+    expect(s.acceptedSnapshot.value?.nodes).toEqual(['old']);
+
+    // The operator picks a different service; its cache entry is older still.
+    key.value = 'p2';
+
+    expect(s.acceptedSnapshot.value, 'the previous question’s cache was drawn').toBeNull();
+    expect(s.phase.value).toBe('loading');
+  });
+
+  // A store that could not be read is a fact about right now, so the previous
+  // map is still the best thing on screen. A DISABLED layer is an
+  // administrator's decision — continuing to draw it states the layer is live
+  // when the person in charge has said it is not.
+  it('clears the graph for a disabled layer, and keeps it for an unreachable store', () => {
+    const disabled = state({
+      data: good(['a']),
+      error: new GraphUnavailableError({ reachable: false, blocked: 'layer-disabled' }),
+    });
+    const unreachable = state({
+      data: good(['a']),
+      error: new GraphUnavailableError({ reachable: false, blocked: 'store-unreachable' }),
+    });
+
+    expect(disabled.acceptedSnapshot.value, 'a disabled layer kept drawing its map').toBeNull();
+    expect(unreachable.acceptedSnapshot.value?.nodes, 'a transient failure erased the map').toEqual(['a']);
+  });
+});
+
+describe('predicate generation', () => {
+  it('advances when the question changes, so anything built on a snapshot can tell', () => {
+    const key = ref('p1');
+    const s = useGraphState<Resp>({
+      data: ref(good(['a'])),
+      error: ref(null),
+      isFetching: ref(false),
+      dataUpdatedAt: ref(Date.now() + 60_000),
+      predicateKey: computed(() => key.value),
+      enabled: computed(() => true),
+      replay: computed(() => false),
+    });
+    const before = s.predicateGeneration.value;
+
+    key.value = 'p2';
+
+    expect(s.predicateGeneration.value).toBe(before + 1);
+  });
+});
+
+describe('useTimeIdentity — keying the question, not the instant', () => {
+  const frozen = computed(() => ({ startMs: 1000, endMs: 2000, step: 'MINUTE' }));
+
+  it('keys a rolling preset by the preset, so moving bounds do not re-key', () => {
+    const id = useTimeIdentity(computed(() => false), frozen);
+    const first = id.value;
+    // A tick re-anchors the store; the identity must not follow it.
+    expect(id.value).toBe(first);
+    expect(first.startsWith('preset:')).toBe(true);
+  });
+
+  it('keys an owned window by its frozen bounds, so two captures cannot collide', () => {
+    const a = useTimeIdentity(computed(() => true), frozen);
+    const b = useTimeIdentity(computed(() => true), computed(() => ({ startMs: 5000, endMs: 6000, step: 'MINUTE' })));
+    expect(a.value).not.toBe(b.value);
+  });
+});
+
+describe('predicateKey — what counts as a different question', () => {
+  const base = { layer: 'general', time: 'preset:1h:MINUTE', cold: false };
+
+  // The defect this replaced: the focus list reached the key through
+  // `Array.join`, which stringifies an object to "[object Object]" — so every
+  // multi-service selection produced the SAME key, and switching services
+  // cleared no placements and refit nothing.
+  it('separates two different service selections', () => {
+    const a = predicateKey({ ...base, focus: [{ id: 'a', name: 'svc-a', normal: true }] });
+    const b = predicateKey({ ...base, focus: [{ id: 'b', name: 'svc-b', normal: true }] });
+    expect(a).not.toBe(b);
+  });
+
+  it('treats a focus SET as order-insensitive', () => {
+    const one = { id: 'a', name: 'svc-a', normal: true };
+    const two = { id: 'b', name: 'svc-b', normal: null };
+    expect(predicateKey({ ...base, focus: [one, two] })).toBe(predicateKey({ ...base, focus: [two, one] }));
+  });
+
+  it('separates cold from hot — a Cold tab must not answer with Hot data', () => {
+    expect(predicateKey({ ...base, cold: true })).not.toBe(predicateKey({ ...base, cold: false }));
+  });
+
+  it('separates depth, endpoint and the instance pair', () => {
+    expect(predicateKey({ ...base, depth: 1 })).not.toBe(predicateKey({ ...base, depth: 2 }));
+    expect(predicateKey({ ...base, endpoint: '/a' })).not.toBe(predicateKey({ ...base, endpoint: '/b' }));
+    expect(predicateKey({ ...base, clientServiceId: 'x' })).not.toBe(predicateKey({ ...base, clientServiceId: 'y' }));
+  });
+
+  it('is stable for the same question built twice', () => {
+    const p = () => predicateKey({ ...base, service: predicateService({ id: 'a', name: 'n', normal: false }) });
+    expect(p()).toBe(p());
+  });
+
+  it('does not alias two fields into one key', () => {
+    expect(predicateKey({ ...base, endpoint: 'a|b' })).not.toBe(predicateKey({ ...base, endpoint: 'a', depth: null }));
+  });
+});
+
+describe('one window per round', () => {
+  it('does not re-anchor while a round is doing it for everybody', async () => {
+    // The failure this prevents is invisible on one screen and obvious across
+    // two: if each subscriber re-anchored, a topology and the dashboard beside
+    // it would ask about windows milliseconds apart and disagree at the edges.
+    const timeRange = useTimeRangeStore();
+    const auto = useAutoRefreshStore();
+    const trigger = useTriggeredRefetch(async () => undefined, computed(() => false));
+    let anchorDuringRound: number | null = null;
+
+    auto.joinRound(async () => {
+      const before = timeRange.range.startMs;
+      // Long enough that a re-anchor lands in a LATER millisecond. Without
+      // the wait both anchors fall in the same one and the comparison would
+      // pass whatever the code did.
+      await new Promise((r) => setTimeout(r, 8));
+      await trigger();
+      anchorDuringRound = timeRange.range.startMs - before;
+    });
+    await auto.refreshNow();
+
+    expect(anchorDuringRound, 'a subscriber moved the round’s window under it').toBe(0);
+  });
+
+  it('DOES re-anchor when nothing else will — a refetch outside any round', async () => {
+    const timeRange = useTimeRangeStore();
+    const trigger = useTriggeredRefetch(async () => undefined, computed(() => false));
+    // Anchored in the past, so a re-anchor is measurable rather than a no-op
+    // that the clock's resolution could hide.
+    timeRange.reanchor();
+    const before = timeRange.range.endMs;
+    await new Promise((r) => setTimeout(r, 5));
+
+    await trigger();
+
+    expect(
+      timeRange.range.endMs,
+      'nothing re-anchored, so a manual refresh would re-request the same window',
+    ).toBeGreaterThan(before);
+  });
+});
+
+describe('a remount asks about now', () => {
+  // Acceptance: with auto-refresh OFF, nothing moves the anchor between opening
+  // a page and coming back to it — so a remount used to re-request the window
+  // as it stood when the app loaded and draw it as current.
+  it('anchors before the first request rather than after it', async () => {
+    const timeRange = useTimeRangeStore();
+    timeRange.reanchor();
+    const openedAt = timeRange.range.endMs;
+    await new Promise((r) => setTimeout(r, 8));
+
+    // What a composable does on mount, BEFORE it builds its query.
+    anchorForMount(computed(() => false));
+
+    expect(
+      timeRange.range.endMs,
+      'the remount would have requested the window from when the app loaded',
+    ).toBeGreaterThan(openedAt);
+  });
+
+  it('leaves a frozen window alone — an embedded capture is not asking about now', async () => {
+    const timeRange = useTimeRangeStore();
+    timeRange.reanchor();
+    const frozen = timeRange.range.endMs;
+    await new Promise((r) => setTimeout(r, 8));
+
+    anchorForMount(computed(() => true));
+
+    expect(timeRange.range.endMs).toBe(frozen);
+  });
+});
+
+describe('the round’s window is only used while it is still the question', () => {
+  // The defect: the picker stays live during a round, so an operator can pick a
+  // new range while one is out. The cache key follows them immediately, but the
+  // REQUEST was being handed the round's older window — filing an answer about
+  // W1 under the key that says W2. Nothing downstream can detect that, and no
+  // later read corrects it.
+  it('hands the request the operator’s window when they change it mid-round', async () => {
+    const timeRange = useTimeRangeStore();
+    const auto = useAutoRefreshStore();
+    const win = useRoundWindow();
+    let duringRound: { startMs: number; endMs: number } | null = null;
+
+    auto.joinRound(() => {
+      // Mid-round, the operator picks an explicit range.
+      timeRange.selectCustom(1_000_000, 2_000_000, 'MINUTE');
+      duringRound = { startMs: win.value.startMs, endMs: win.value.endMs };
+    });
+    await auto.refreshNow();
+
+    expect(duringRound, 'the round ran no subscriber').not.toBeNull();
+    expect(duringRound!.startMs, 'the request asked about the round’s stale window').toBe(1_000_000);
+    expect(duringRound!.endMs).toBe(2_000_000);
+  });
+
+  it('otherwise uses the round’s own window, so one round is one window', async () => {
+    const auto = useAutoRefreshStore();
+    const win = useRoundWindow();
+    const seen: number[] = [];
+    auto.joinRound(() => { seen.push(win.value.endMs); });
+    auto.joinRound(() => { seen.push(win.value.endMs); });
+
+    await auto.refreshNow();
+
+    expect(seen).toHaveLength(2);
+    expect(seen[0], 'two screens in one round described different windows').toBe(seen[1]);
+    expect(seen[0]).toBe(auto.currentRound?.endMs ?? seen[0]);
+  });
+});
+
+/**
+ * The four acceptance items whose coverage would not have caught a regression.
+ *
+ * Each is written against the state machine rather than a rendered page,
+ * because that is where the decision is actually made — and each was checked
+ * by removing the behaviour and confirming the assertion fails.
+ */
+describe('acceptance: switching from A to B', () => {
+  const resp = (nodes: string[]): Resp => ({ reachable: true, nodes });
+
+  function screen(initialKey: string) {
+    const key = ref(initialKey);
+    const data = ref<Resp | undefined>(undefined);
+    const fetchedAt = ref(0);
+    const fetching = ref(false);
+    const state = useGraphState<Resp>({
+      data,
+      error: ref(null),
+      isFetching: fetching,
+      dataUpdatedAt: fetchedAt,
+      predicateKey: computed(() => key.value),
+      enabled: computed(() => true),
+      replay: computed(() => false),
+    });
+    /** A read landing for whatever question is on screen now. */
+    const land = (nodes: string[]) => {
+      data.value = resp(nodes);
+      fetchedAt.value = Date.now() + 1000;
+    };
+    return { key, data, fetchedAt, fetching, state, land };
+  }
+
+  // §十.1 — while B is pending, A's graph is already gone and the screen says
+  // what it is loading. Previously the phase could not tell "no data yet" from
+  // "failed", so this state was indistinguishable from a permanent wait.
+  it('1: A is gone and B reads as loading while B is pending', () => {
+    const s = screen('A');
+    s.land(['a1', 'a2']);
+    expect(s.state.acceptedSnapshot.value?.nodes).toEqual(['a1', 'a2']);
+
+    // Switching moves the observer to B's own cache entry. B has never been
+    // read, so there is nothing there — which is what the query library hands
+    // over, and what this models.
+    s.key.value = 'B';
+    s.data.value = undefined;
+    s.fetching.value = true;
+
+    expect(s.state.acceptedSnapshot.value, 'A’s graph was still on screen under B').toBeNull();
+    // LOADING, not failed: nothing has gone wrong, B simply has no answer yet.
+    // The two were indistinguishable before the phase machine, so a first
+    // failure showed this same line for ever.
+    expect(s.state.phase.value).toBe('loading');
+  });
+
+  // §十.2 — B having a cache entry does not let it skip the request, and A's
+  // late answer must not be committed to B.
+  it('2: a cached B is not drawn until it has been read for B', () => {
+    const s = screen('A');
+    s.land(['a1']);
+    // B was visited earlier: its entry exists, fetched before the switch.
+    s.key.value = 'B';
+    s.data.value = resp(['b-stale']);
+    s.fetchedAt.value = Date.now() - 60_000;
+
+    expect(s.state.acceptedSnapshot.value, 'B’s stale cache was drawn immediately').toBeNull();
+
+    // The read for B lands.
+    s.land(['b1']);
+    expect(s.state.acceptedSnapshot.value?.nodes).toEqual(['b1']);
+  });
+
+  it('2b: a generation advances on every switch, so a late reply can be told apart', () => {
+    const s = screen('A');
+    const g0 = s.state.predicateGeneration.value;
+    s.key.value = 'B';
+    expect(s.state.predicateGeneration.value).toBe(g0 + 1);
+    s.key.value = 'C';
+    expect(s.state.predicateGeneration.value).toBe(g0 + 2);
+  });
+
+  // §十.3 — same predicate, good then failure: the whole snapshot is unchanged.
+  it('3: a failure on the same question changes nothing that is drawn', () => {
+    const key = ref('A');
+    const data = ref<Resp | undefined>(resp(['a1', 'a2']));
+    const error = ref<Error | null>(null);
+    const s = useGraphState<Resp>({
+      data,
+      error,
+      isFetching: ref(false),
+      dataUpdatedAt: ref(Date.now() + 1000),
+      predicateKey: computed(() => key.value),
+      enabled: computed(() => true),
+      replay: computed(() => false),
+    });
+    const before = s.acceptedSnapshot.value;
+
+    error.value = new GraphUnavailableError({ reachable: false });
+
+    expect(s.acceptedSnapshot.value, 'the drawn graph changed on a failed refresh').toBe(before);
+    expect(s.phase.value).toBe('ready');
+    // The banner reads the ATTEMPT, which is the failure — a different source
+    // from the graph, deliberately.
+    expect(s.latestAttempt.value?.reachable).toBe(false);
+  });
+});
diff --git a/apps/ui/src/layer/graphQuery.ts b/apps/ui/src/layer/graphQuery.ts
new file mode 100644
index 00000000..e341fa06
--- /dev/null
+++ b/apps/ui/src/layer/graphQuery.ts
@@ -0,0 +1,467 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * What every graph screen needs from its query, and none of them can express
+ * alone: a cache key that identifies the QUESTION rather than the clock, and a
+ * snapshot that survives an answer not worth drawing.
+ *
+ * The four graph screens (service map, deployment, endpoint dependency,
+ * instance relationship) each own their own fetch, layout and render. Only
+ * these two decisions are shared, and they are shared because getting either
+ * wrong empties the canvas in front of an operator mid-incident.
+ */
+
+import { computed, ref, watch } from 'vue';
+import type { ComputedRef, Ref } from 'vue';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import { useColdStageStore } from '@/controls/coldStage';
+import { useAutoRefreshStore } from '@/controls/autoRefresh';
+
+/** Anything the graph routes return. They all carry this flag; see `accepts`. */
+export interface GraphResponse {
+  reachable?: boolean;
+  /** Why the route served nothing, when it is not a failure to reach OAP.
+   *  `layer-disabled` is authoritative and clears the graph; anything else is
+   *  transient and keeps it. */
+  blocked?: 'store-unreachable' | 'layer-disabled';
+}
+
+/**
+ * Is this response worth DRAWING?
+ *
+ * The BFF's graph builders never throw: an OAP failure comes back as HTTP 200
+ * with `{ nodes: [], reachable: false }`. To a query library that is a resolved
+ * success, so the cached graph is replaced by the empty one and the canvas goes
+ * blank — with nothing pending to wait on and no error to retry.
+ *
+ * `reachable` is the only thing that separates "the graph is empty" from "we
+ * could not read the graph", and the two must not be conflated: an empty graph
+ * is a fact worth showing, a failed read is not. Everything else is accepted on
+ * purpose — a `tooLarge` response is deliberately `reachable: true` with no
+ * nodes, and `metricsPartial` is a perfectly good graph whose metric chunks
+ * partly failed.
+ */
+export function accepts<T extends GraphResponse>(resp: T | null | undefined): boolean {
+  return resp != null && resp.reachable !== false;
+}
+
+/**
+ * A response that arrived but must not be drawn.
+ *
+ * Thrown from the query function rather than returned, and that is the whole
+ * mechanism: a query library keeps the last SUCCESSFUL data when a fetch
+ * fails, so throwing here makes the last good graph survive both a failed
+ * round and a remount — it lives in the query cache, not in a ref that dies
+ * with the component. Returning the empty body instead replaced the cached
+ * graph with it, which is how a backend failure erased the picture.
+ *
+ * It carries the response so the banner can still say what happened.
+ */
+export class GraphUnavailableError extends Error {
+  constructor(readonly response: GraphResponse) {
+    // A sentence, not a code. This message is what the failure history shows
+    // an operator, and "graph unavailable" beside a blank canvas says only
+    // what they can already see.
+    super('OAP could not be reached, so the graph was left as it was.');
+    this.name = 'GraphUnavailableError';
+  }
+}
+
+/**
+ * Wrap a graph fetch so an unacceptable answer becomes a failure.
+ *
+ * Every graph route answers HTTP 200 even when OAP could not be reached, so
+ * without this the "success" path receives `{nodes: [], reachable: false}` and
+ * has no way to refuse it.
+ */
+export async function fetchDrawable<T extends GraphResponse>(
+  fetch: () => Promise<T>,
+  /** The window this request asked about, remembered WITH the response. */
+  window?: { step: string; startMs: number; endMs: number },
+): Promise<T> {
+  const resp = await fetch();
+  if (!accepts(resp)) throw new GraphUnavailableError(resp);
+  if (window) windowByResponse.set(resp as object, { ...window });
+  return resp;
+}
+
+/**
+ * The window each response was fetched with, keyed by the response itself.
+ *
+ * A snapshot outlives the window that produced it — that is the whole point of
+ * keeping the last good one — so anything merged INTO a snapshot has to ask
+ * about the window that snapshot was read with, not whatever the clock says.
+ * Deriving it from a ref could not work: on a remount the accepted snapshot
+ * comes from the cache, possibly minutes old, while every ref has just been
+ * initialised from the current window. Keyed by object identity, the two
+ * cannot drift apart. A WeakMap, so a response that falls out of the cache
+ * takes its entry with it.
+ */
+const windowByResponse = new WeakMap<object, { step: string; startMs: number; endMs: number }>();
+
+/** The window a response was fetched with, or null if it was not recorded. */
+export function windowOf(
+  resp: unknown,
+): { step: string; startMs: number; endMs: number } | null {
+  if (!resp || typeof resp !== 'object') return null;
+  return windowByResponse.get(resp as object) ?? null;
+}
+
+/**
+ * What the screen is doing, as one value.
+ *
+ * Derived rather than stored, so a view cannot read half of it from a snapshot
+ * and half from the attempt that failed:
+ *
+ * - `disabled`   — nothing is coming; the query is gated off.
+ * - `loading`    — nothing to draw for THIS predicate yet.
+ * - `failed`     — the first attempt for this predicate failed. Distinct from
+ *                  `loading`, which it used to be indistinguishable from: an
+ *                  empty drawable was read as "still waiting" and the canvas
+ *                  showed "Reading data…" for ever.
+ * - `refreshing` — a graph is on screen and a new round is out.
+ * - `ready`      — a graph is on screen and nothing is in flight.
+ */
+export type GraphPhase = 'disabled' | 'loading' | 'failed' | 'refreshing' | 'ready';
+
+export interface GraphState<T extends GraphResponse> {
+  /**
+   * The last response worth drawing FOR THE CURRENT PREDICATE.
+   *
+   * From the query cache, so it survives a failed round and a remount — but
+   * gated on the predicate it belongs to, so a question the operator asked a
+   * moment ago cannot answer the one they are asking now.
+   */
+  acceptedSnapshot: ComputedRef<T | null>;
+  /** Whatever came back last, drawable or not. Drives the banners. */
+  latestAttempt: ComputedRef<T | null>;
+  phase: ComputedRef<GraphPhase>;
+  /** Increments on every predicate change. A late response, an expansion, or
+   *  anything else built on a snapshot can compare against this to know
+   *  whether it still belongs to the question on screen. */
+  predicateGeneration: ComputedRef<number>;
+}
+
+/**
+ * Assemble the state a graph screen renders from.
+ *
+ * `replay` short-circuits everything: a captured block renders its payload and
+ * fires no query, so its phase is `ready` from the first frame rather than the
+ * permanent `pending` a disabled query reports.
+ */
+export function useGraphState<T extends GraphResponse>(opts: {
+  data: Ref<T | undefined> | ComputedRef<T | undefined>;
+  error: Ref<Error | null> | ComputedRef<Error | null>;
+  isFetching: Ref<boolean> | ComputedRef<boolean>;
+  /** When the cached data was last FETCHED. The gate below is built on it. */
+  dataUpdatedAt: Ref<number> | ComputedRef<number>;
+  /** Identity of the question. A change starts a new generation. */
+  predicateKey: ComputedRef<string>;
+  enabled: ComputedRef<boolean>;
+  replay: ComputedRef<boolean>;
+  replayData?: Ref<T | null>;
+}): GraphState<T> {
+  const generation = ref(0);
+  /**
+   * When the operator last CHANGED the question — zero until they do.
+   *
+   * Two rules pull in opposite directions here and both matter:
+   *
+   * - Switching to a service visited a minute ago must not draw that
+   *   minute-old graph instantly under the new heading with no loading state,
+   *   only to swap it when the real answer lands. So after a change, a cache
+   *   entry older than the change is refused until it has been re-read.
+   * - Re-MOUNTING the same question must keep showing its last good graph —
+   *   that is the whole reason the snapshot lives in the query cache rather
+   *   than in a ref that dies with the component.
+   *
+   * Zero is what separates them. A component that has not yet seen the
+   * question change accepts whatever the cache holds for it, because that IS
+   * its last good answer; only a change during its life sets a floor.
+   */
+  const askedAt = ref(0);
+  watch(
+    opts.predicateKey,
+    () => {
+      generation.value += 1;
+      askedAt.value = Date.now();
+    },
+    // SYNC: the render that follows a predicate change must already see the
+    // canvas cleared, not clear it a tick later.
+    { flush: 'sync' },
+  );
+
+  const failedResponse = computed<T | null>(() => {
+    const e = opts.error.value;
+    return e instanceof GraphUnavailableError ? (e.response as T) : null;
+  });
+  /**
+   * An administrator's decision, not a transient failure.
+   *
+   * A store that could not be read is a fact about right now, so the previous
+   * graph is still the best thing on screen. A DISABLED layer is authoritative:
+   * continuing to draw its map states that the layer is live when the operator
+   * in charge has said it is not.
+   */
+  const layerDisabled = computed(() => failedResponse.value?.blocked === 'layer-disabled');
+
+  const acceptedSnapshot = computed<T | null>(() => {
+    if (opts.replay.value) return opts.replayData?.value ?? null;
+    if (layerDisabled.value) return null;
+    const data = opts.data.value;
+    if (data === undefined) return null;
+    // Fetched before this predicate was asked ⇒ it answers the previous one.
+    return opts.dataUpdatedAt.value >= askedAt.value ? data : null;
+  });
+  const latestAttempt = computed<T | null>(() => failedResponse.value ?? acceptedSnapshot.value);
+  const phase = computed<GraphPhase>(() => {
+    if (opts.replay.value) return 'ready';
+    if (!opts.enabled.value) return 'disabled';
+    if (acceptedSnapshot.value === null) return opts.error.value ? 'failed' : 'loading';
+    return opts.isFetching.value ? 'refreshing' : 'ready';
+  });
+  return {
+    acceptedSnapshot,
+    latestAttempt,
+    phase,
+    predicateGeneration: computed(() => generation.value),
+  };
+}
+
+/**
+ * One SERVICE as a predicate names it. Normalized so two spellings of the same
+ * selection cannot produce two keys.
+ */
+export interface PredicateService {
+  id: string;
+  name: string;
+  normal: boolean | null;
+}
+
+/**
+ * WHAT is being asked — every input that changes the answer, and nothing that
+ * merely changes when it is asked.
+ *
+ * One object, so the query key, the reset watcher and the request parameters
+ * are all derived from the same value rather than assembled separately. They
+ * were assembled separately once, and the focus list went into the key through
+ * `Array.join`, which stringifies an object to `[object Object]` — so every
+ * multi-service selection produced an IDENTICAL key and switching services
+ * cleared no placements, refit nothing, and left the previous graph drawn
+ * under the new heading.
+ */
+export interface GraphPredicate {
+  layer: string;
+  /** Order-insensitive: a selection is a SET, so it is sorted before keying. */
+  focus?: readonly PredicateService[];
+  service?: PredicateService | null;
+  endpoint?: string | null;
+  clientServiceId?: string | null;
+  serverServiceId?: string | null;
+  depth?: number | null;
+  /** From `useTimeIdentity` — a preset, a custom window, or a frozen one. */
+  time: string;
+  /** The unpublished draft this view renders, if any. */
+  preview?: unknown;
+  /**
+   * Cold stage REPLACES the hot read rather than widening it, so it is part of
+   * the question, not a header that rides along. A Cold tab showing Hot data
+   * because the key did not move is the failure this prevents.
+   */
+  cold: boolean;
+}
+
+/** A `ServiceRef`-shaped value reduced to what identifies it. */
+export function predicateService(
+  s: { id: string; name: string; normal?: boolean | null } | null | undefined,
+): PredicateService | null {
+  if (!s) return null;
+  return { id: s.id, name: s.name, normal: s.normal ?? null };
+}
+
+/**
+ * The predicate as one comparable string.
+ *
+ * Written by hand rather than `JSON.stringify(predicate)`, because that is
+ * key-order sensitive: two objects describing the same question serialise
+ * differently depending on how they were built, and the watcher then fires on
+ * a change that did not happen.
+ */
+export function predicateKey(p: GraphPredicate): string {
+  const svc = (s: PredicateService | null | undefined): string =>
+    s ? `${s.id}~${s.name}~${s.normal === null ? '' : String(s.normal)}` : '';
+  const focus = [...(p.focus ?? [])]
+    .map(svc)
+    .sort()
+    .join('+');
+  return [
+    `l=${p.layer}`,
+    `f=${focus}`,
+    `s=${svc(p.service)}`,
+    `e=${p.endpoint ?? ''}`,
+    `c=${p.clientServiceId ?? ''}`,
+    `S=${p.serverServiceId ?? ''}`,
+    `d=${p.depth ?? ''}`,
+    `t=${p.time}`,
+    `p=${p.preview === undefined || p.preview === null ? '' : JSON.stringify(p.preview)}`,
+    `k=${p.cold ? '1' : '0'}`,
+  ].join('|');
+}
+
+/**
+ * The time part of a query key — an IDENTITY, not an instant.
+ *
+ * A rolling window's bounds move on every tick. Putting them in the key makes
+ * every tick a cache miss, which is what empties these canvases; putting the
+ * PRESET in instead keys the question ("the last hour") rather than the answer
+ * ("10:04:03 to 11:04:03"). The bounds still travel as the request argument,
+ * re-read at the moment the request is made.
+ *
+ * Three forms, and collapsing them is a bug in each direction:
+ *
+ * - **rolling** — the preset id plus the step. The bounds are derived and move.
+ * - **custom** — the explicit bounds ARE the predicate here, and they do not
+ *   move, so they belong in the key. Keying a custom range by "custom" alone
+ *   would serve one custom window's answer for another's question.
+ * - **owned** — an embedded block froze its own window at mount. Its bounds go
+ *   in the key so two captured blocks on one page cannot share a cache entry.
+ */
+export function useTimeIdentity(
+  ownsWindow?: ComputedRef<boolean>,
+  /** Required only when `ownsWindow` can be true. A screen that always reads
+   *  the topbar's window — an overview, a layer dashboard — passes neither. */
+  ownWindow?: ComputedRef<{ startMs: number; endMs: number; step: string }>,
+): ComputedRef<string> {
+  const timeRange = useTimeRangeStore();
+  const cold = useColdStageStore();
+  // The STAGE is part of the question, not a header that rides along with it.
+  // Cold replaces the hot read rather than widening it, so an answer from one
+  // stage is not an answer to the other's question — and a key that ignored it
+  // let a Cold tab keep showing Hot data until something else moved the key.
+  const stage = computed(() => (cold.enabled ? ':cold' : ''));
+  return computed(() => {
+    if (ownsWindow?.value && ownWindow) {
+      const w = ownWindow.value;
+      return `own:${w.step}:${w.startMs}:${w.endMs}${stage.value}`;
+    }
+    if (timeRange.presetId === 'custom') {
+      return `custom:${timeRange.step}:${timeRange.customStartMs}:${timeRange.customEndMs}${stage.value}`;
+    }
+    return `preset:${timeRange.presetId}:${timeRange.step}${stage.value}`;
+  });
+}
+
+/**
+ * The window a request should ask about, right now.
+ *
+ * While a round is out this is the ROUND's window, frozen when the round
+ * started. Outside a round it is the topbar's own. Reading the store directly
+ * was correct only by accident: it happened to hold the round's values because
+ * the round re-anchors once and nothing else moves it mid-round — a property
+ * of today's code rather than a guarantee, and one that a picker, a preset
+ * change or a second re-anchor would quietly break. A round is supposed to be
+ * ONE question asked of every screen; taking the window from the round itself
+ * is what makes that structural.
+ */
+export function useRoundWindow(): ComputedRef<{
+  step: 'MINUTE' | 'HOUR' | 'DAY';
+  startMs: number;
+  endMs: number;
+}> {
+  const timeRange = useTimeRangeStore();
+  const auto = useAutoRefreshStore();
+  return computed(() => {
+    const live = {
+      step: timeRange.step,
+      startMs: timeRange.range.startMs,
+      endMs: timeRange.range.endMs,
+    };
+    const round = auto.currentRound;
+    if (!round) return live;
+    // The round's window, but ONLY while it still describes what is being
+    // asked about. An operator can change the range mid-round — the picker
+    // stays live — and the cache key follows them immediately. Handing the
+    // request the round's older window then files an answer about W1 under the
+    // key that says W2, which nothing downstream can detect and no later read
+    // corrects. When the two disagree the operator's is the real question; the
+    // round that follows re-establishes one window for everybody.
+    if (round.step !== live.step || round.startMs !== live.startMs || round.endMs !== live.endMs) {
+      return live;
+    }
+    return {
+      step: round.step as 'MINUTE' | 'HOUR' | 'DAY',
+      startMs: round.startMs,
+      endMs: round.endMs,
+    };
+  });
+}
+
+/**
+ * Anchor a rolling window to NOW, before the query that will read it is built.
+ *
+ * MUST be called ahead of `useQuery`. The query fetches on mount, reading the
+ * window as it stands at that moment; with auto-refresh off nothing had moved
+ * the anchor since the page was opened, so a remount ten minutes later
+ * re-requested the ten-minute-old window and drew it as current. Anchoring
+ * afterwards does not help — by then the request is already built.
+ *
+ * A frozen window (an embedded capture) and a custom absolute range are the
+ * operator's own and never move. During a round the round has already
+ * anchored, once, for everybody.
+ */
+export function anchorForMount(ownsWindow: ComputedRef<boolean>): void {
+  const timeRange = useTimeRangeStore();
+  const auto = useAutoRefreshStore();
+  if (!ownsWindow.value && timeRange.presetId !== 'custom' && !auto.roundRunning) {
+    timeRange.reanchor();
+  }
+}
+
+/**
+ * Re-anchor a rolling window, then refetch — in that order.
+ *
+ * Once the key stops carrying the bounds, `refetch()` alone re-requests the
+ * window the query already holds: rolling bounds advance only when the store
+ * re-anchors, and nothing in the refetch path does that. So a manual refresh
+ * would return the same answer and look broken.
+ *
+ * A rolling window is anchored at the moment the request is TRIGGERED. "Last
+ * hour" means the hour ending when you asked, never the hour ending when the
+ * cache entry happened to be created.
+ *
+ * NOT inside a round, though. A round anchors ONCE, before it calls anybody,
+ * and every screen in it must ask about that same window — re-anchoring here
+ * as well would move the window between one subscriber and the next, so a
+ * topology and the dashboard beside it would answer about windows a few
+ * milliseconds apart and quietly disagree at their edges.
+ */
+export function useTriggeredRefetch(
+  refetch: () => Promise<unknown>,
+  ownsWindow: ComputedRef<boolean>,
+): () => Promise<unknown> {
+  const timeRange = useTimeRangeStore();
+  const auto = useAutoRefreshStore();
+  return async () => {
+    // An owned window is frozen by definition, and a custom range's bounds are
+    // the operator's own — neither re-anchors.
+    const mine = !auto.roundRunning;
+    if (mine && !ownsWindow.value && timeRange.presetId !== 'custom') timeRange.reanchor();
+    return refetch();
+  };
+}
+
diff --git a/apps/ui/src/layer/service-map/LayerDeploymentView.vue b/apps/ui/src/layer/service-map/LayerDeploymentView.vue
index 4b9e21aa..bed90f54 100644
--- a/apps/ui/src/layer/service-map/LayerDeploymentView.vue
+++ b/apps/ui/src/layer/service-map/LayerDeploymentView.vue
@@ -127,12 +127,24 @@ const focusWindowMinutes = computed<number | null>(() =>
   embedded.value ? (props.focusWindowMinutes ?? 60) : null,
 );
 const replayDataRef = computed<DeploymentResponse | null>(() => props.replayData ?? null);
-const { data, nodes, calls, isFetching } = useDeployment(layerKey, service, enabled, focusWindowMinutes, replayDataRef);
-const serviceName = computed(() => displayServiceName(data.value?.serviceName) || '');
-const metricsPartial = computed(() => data.value?.metricsPartial ?? null);
+const { data, acceptedSnapshot, nodes, calls, isFetching, phase, predicateKey, refetch } =
+  useDeployment(layerKey, service, enabled, focusWindowMinutes, replayDataRef);
+// The drawn graph's service, not the failed attempt's — a failed round emptied
+// the heading above a map that was still showing that service's pods.
+const serviceName = computed(() => displayServiceName(acceptedSnapshot.value?.serviceName) || '');
+// Fields that DESCRIBE THE DRAWN GRAPH come from the snapshot; only the fields
+// that describe the latest ATTEMPT (reachable / blocked / error) come from
+// `data`. Reading them all from `data` mixed the two: after a failed round the
+// nodes were still the snapshot's while the config, size warning and partial-
+// metric notice came from the empty failure — so a drawn graph lost its metric
+// definitions and its warnings the moment a refresh could not be read.
+/** WHY it is empty, when the answer is not "OAP could not be read". Read from
+ *  the latest ATTEMPT — it describes the failure, not the drawn graph. */
+const blocked = computed(() => data.value?.blocked ?? null);
+const metricsPartial = computed(() => acceptedSnapshot.value?.metricsPartial ?? null);
 
 const cfg = computed<DeploymentConfig>(
-  () => data.value?.config ?? { nodeMetrics: [] },
+  () => acceptedSnapshot.value?.config ?? { nodeMetrics: [] },
 );
 function pickByRole(defs: DeploymentMetricDef[], role: DeploymentMetricDef['role']): DeploymentMetricDef | null {
   return defs.find((d) => d.role === role) ?? null;
@@ -322,18 +334,59 @@ const layout = computed<Layout>(() =>
 );
 const basePos = computed(() => layout.value.pos);
 const nodeToPod = computed(() => layout.value.nodeToPod);
-// Per-pod drag offsets (move a whole pod — main + its siblings — as a unit).
-// Keyed by podId; reset when the selected service changes.
-const podDelta = ref<Map<string, { dx: number; dy: number }>>(new Map());
+/** Where the packing put each pod — its nodes' centroid. The anchor below is
+ *  measured against this, so it needs the same reference point the drag did. */
+const podBase = computed<Map<string, { cx: number; cy: number }>>(() => {
+  const sums = new Map<string, { x: number; y: number; n: number }>();
+  for (const [id, p] of basePos.value) {
+    const pid = nodeToPod.value.get(id);
+    if (!pid) continue;
+    const acc = sums.get(pid) ?? { x: 0, y: 0, n: 0 };
+    acc.x += p.cx;
+    acc.y += p.cy;
+    acc.n += 1;
+    sums.set(pid, acc);
+  }
+  const out = new Map<string, { cx: number; cy: number }>();
+  for (const [pid, acc] of sums) out.set(pid, { cx: acc.x / acc.n, cy: acc.y / acc.n });
+  return out;
+});
+/**
+ * Where the operator PUT each pod, in absolute canvas coordinates.
+ *
+ * An offset from the packed position was the obvious shape and the wrong one:
+ * when a refresh adds a pod the packing shifts everything, and re-applying the
+ * same delta to a moved base carries the operator's pod along with the shift —
+ * so a pod they had set aside slides across the canvas on a refresh that had
+ * nothing to do with it. An absolute anchor stays put; only pods that were
+ * never touched follow the packing.
+ */
+const podAnchor = ref<Map<string, { cx: number; cy: number }>>(new Map());
 const pos = computed<Map<string, Pos>>(() => {
-  if (podDelta.value.size === 0) return basePos.value;
+  if (podAnchor.value.size === 0) return basePos.value;
   const m = new Map<string, Pos>();
   for (const [id, p] of basePos.value) {
-    const d = podDelta.value.get(nodeToPod.value.get(id) ?? '');
-    m.set(id, d ? { cx: p.cx + d.dx, cy: p.cy + d.dy, r: p.r } : p);
+    const pid = nodeToPod.value.get(id) ?? '';
+    const anchor = podAnchor.value.get(pid);
+    const base = podBase.value.get(pid);
+    if (!anchor || !base) {
+      m.set(id, p);
+      continue;
+    }
+    m.set(id, { cx: p.cx + (anchor.cx - base.cx), cy: p.cy + (anchor.cy - base.cy), r: p.r });
   }
   return m;
 });
+// Pods that are gone take their anchor with them; a pod that comes BACK under
+// the same id is deliberately put back where the operator left it. Pruned from
+// the drawn layout, which a failed round leaves untouched — so a refresh that
+// could not be read never discards a placement.
+watch(podBase, (bases) => {
+  if (podAnchor.value.size === 0) return;
+  const kept = new Map<string, { cx: number; cy: number }>();
+  for (const [pid, a] of podAnchor.value) if (bases.has(pid)) kept.set(pid, a);
+  if (kept.size !== podAnchor.value.size) podAnchor.value = kept;
+});
 const W = computed(() => layout.value.w);
 const H = computed(() => layout.value.h);
 // The canvas needs a CONCRETE height — the tab outlet sizes by content, so a
@@ -459,14 +512,17 @@ function edgePathD(c: DeploymentCall): string {
 }
 
 // ── Pan + zoom (same lifecycle as the instance map) — owned by the composable.
-// The dataset-identity signal folds in selectedId so a service switch that
-// lands on cached data with identical counts still re-keys every v-for node
-// element (which kills the per-element d3 drag listeners) → rebind + refit.
+// The rebinding signal is the IDENTITY of what is drawn, not how much of it.
+// Counts were the old signal, and a refresh that dropped one instance and
+// gained another kept every count identical — so the `<g>` elements were
+// re-keyed while the signal said nothing had changed, and dragging silently
+// stopped working until the next time a count happened to move.
 const svgEl = ref<SVGSVGElement | null>(null);
 const zoomLayerEl = ref<SVGGElement | null>(null);
 const containerEl = ref<HTMLDivElement | null>(null);
 const datasetKey = computed(
-  () => `${selectedId.value}|${nodes.value.length}|${visibleCalls.value.length}|${clusters.value.length}`,
+  () =>
+    `${selectedId.value}|${[...nodes.value.map((n) => n.id)].sort().join(',')}|${clusters.value.length}`,
 );
 const { zoomT, fitToScreen, zoomBy } = useDeploymentPanZoom({
   svgEl,
@@ -475,8 +531,10 @@ const { zoomT, fitToScreen, zoomBy } = useDeploymentPanZoom({
   W,
   H,
   nodeToPod,
-  podDelta,
+  podAnchor,
+  podBase,
   datasetKey,
+  predicateKey,
 });
 
 // ── Selection (edge → sidebar, node → popover). Reset on service change.
@@ -490,7 +548,15 @@ function selectNode(id: string): void {
   selectedCallId.value = null;
   popoverNodeId.value = popoverNodeId.value === id ? null : id;
 }
-watch(selectedId, () => { selectedCallId.value = null; popoverNodeId.value = null; podDelta.value = new Map(); mapTab.value = 'topology'; });
+watch(selectedId, () => { selectedCallId.value = null; popoverNodeId.value = null; podAnchor.value = new Map(); mapTab.value = 'topology'; });
+// The service is only part of the question. A time-range switch or a Hot→Cold
+// flip is a different read of a different window, so the pods the operator
+// placed and whatever they had selected belong to the picture being replaced.
+watch(predicateKey, () => {
+  selectedCallId.value = null;
+  popoverNodeId.value = null;
+  podAnchor.value = new Map();
+});
 const selectedCall = computed<DeploymentCall | null>(
   () => calls.value.find((c) => c.id === selectedCallId.value) ?? null,
 );
@@ -740,8 +806,16 @@ function selectEdgeFromFlows(id: string): void {
 }
 
 const showPickPrompt = computed(() => !enabled.value);
-const showLoading = computed(() => enabled.value && isFetching.value && nodes.value.length === 0);
-const isEmpty = computed(() => enabled.value && !isFetching.value && nodes.value.length === 0);
+// Loading means "nothing to draw for the CURRENT predicate", not "a request is
+// in flight" — gating on the latter blanked the canvas on every refresh. And it
+// NAMES what it is loading, so an operator who just switched service can see
+// which one they are waiting for rather than a bare "Reading data…".
+const showLoading = computed(() => phase.value === 'loading');
+const showFailed = computed(() => phase.value === 'failed');
+const loadingTarget = computed(() => service.value?.name ?? '');
+const isEmpty = computed(
+  () => (phase.value === 'ready' || phase.value === 'refreshing') && nodes.value.length === 0,
+);
 
 function onKeyDown(e: KeyboardEvent): void {
   if (e.key !== 'Escape') return;
@@ -776,7 +850,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
     <div class="sit-body" :class="{ 'no-selection': !selectedCall || mapTab === 'flows' }">
       <div v-show="mapTab === 'topology'" ref="containerEl" class="sit-canvas" :style="{ height: canvasHeightPx + 'px' }">
         <div v-if="showPickPrompt" class="sit-state">{{ t('Pick a service to see its deployment topology.') }}</div>
-        <div v-else-if="showLoading" class="sit-state">{{ t('Reading data…') }}</div>
+        <div v-else-if="showLoading" class="sit-state">{{ t('Loading deployment for “{target}”…', { target: loadingTarget }) }}</div>
+        <!-- A failed FIRST read is its own state. Left as "loading" it showed
+             the waiting line for ever, with nothing coming. -->
+        <!-- A DISABLED layer is an administrator's answer, not a failure to
+             reach anything: offering Retry would invite the operator to keep
+             asking for something that will not change until someone re-enables
+             it. -->
+        <div v-else-if="blocked === 'layer-disabled'" class="sit-state">
+          {{ t('This layer’s template is disabled.') }}
+          {{ t('An administrator turned it off — re-enable it under Operate → Templates.') }}
+        </div>
+        <div v-else-if="showFailed" class="sit-state">
+          {{ t('Could not load the deployment topology.') }}
+          <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
+        </div>
         <div v-else-if="isEmpty" class="sit-state">{{ t('No deployment topology in this window.') }}</div>
         <template v-else>
           <svg ref="svgEl" class="sit-svg" width="100%" height="100%">
diff --git a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
index df27836a..97836aa8 100644
--- a/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
+++ b/apps/ui/src/layer/service-map/LayerInstanceTopologyView.vue
@@ -214,7 +214,8 @@ function pick(which: 'client' | 'server', val: string): void {
 
 const enabled = computed(() => !!clientId.value && !!serverId.value);
 const replayDataRef = computed<InstanceTopologyResponse | null>(() => props.replayData ?? null);
-const { data, nodes, calls, isFetching } = useInstanceTopology(
+const { data, acceptedSnapshot, nodes, calls, isFetching, phase, predicateKey, refetch } =
+  useInstanceTopology(
   layerKey,
   clientId,
   serverId,
@@ -222,17 +223,33 @@ const { data, nodes, calls, isFetching } = useInstanceTopology(
   focusWindowMinutes,
   replayDataRef,
 );
-const metricsPartial = computed(() => data.value?.metricsPartial ?? null);
+// Fields that DESCRIBE THE DRAWN GRAPH come from the snapshot; only the fields
+// that describe the latest ATTEMPT (reachable / blocked / error) come from
+// `data`. Reading them all from `data` mixed the two: after a failed round the
+// nodes were still the snapshot's while the config, size warning and partial-
+// metric notice came from the empty failure — so a drawn graph lost its metric
+// definitions and its warnings the moment a refresh could not be read.
+/** WHY it is empty, when the answer is not "OAP could not be read". Read from
+ *  the latest ATTEMPT — it describes the failure, not the drawn graph. */
+const blocked = computed(() => data.value?.blocked ?? null);
+const metricsPartial = computed(() => acceptedSnapshot.value?.metricsPartial ?? null);
 
 // Resolve both names through the SAME layer naming rule the service map
 // uses (`resolveServiceIdentity` → `display`), so the `<group>::` prefix
 // is stripped here exactly as it is on the service-topology nodes — an
 // edge between `agent::app` and `agent::gateway` shows `app` / `gateway`
 // in both maps, not raw on one and stripped on the other.
-const clientName = computed(() => displayName(data.value?.clientServiceName) || nameOf(clientId.value));
-const serverName = computed(() => displayName(data.value?.serverServiceName) || nameOf(serverId.value));
+// From the SNAPSHOT: these NAME the graph on screen. Off the latest attempt
+// they fell back to raw ids the moment a refresh failed — the map still drawn,
+// its two ends suddenly unnamed.
+const clientName = computed(
+  () => displayName(acceptedSnapshot.value?.clientServiceName) || nameOf(clientId.value),
+);
+const serverName = computed(
+  () => displayName(acceptedSnapshot.value?.serverServiceName) || nameOf(serverId.value),
+);
 
-const cfg = computed(() => data.value?.config ?? { nodeMetrics: [] as TopologyMetricDef[] });
+const cfg = computed(() => acceptedSnapshot.value?.config ?? { nodeMetrics: [] as TopologyMetricDef[] });
 function pickByRole(defs: TopologyMetricDef[], role: TopologyMetricDef['role']): TopologyMetricDef | null {
   return defs.find((d) => d.role === role) ?? null;
 }
@@ -439,11 +456,32 @@ function installZoom(): void {
   sel.on('dblclick.zoom', null);
   sel.on('dblclick', () => fitToScreen(true));
 }
+/** Whether the last fit had a graph to measure. A fit taken while the canvas
+ *  was empty framed nothing, so it must not stand once nodes arrive. */
+let fittedWithContent = false;
 function installZoomAndFit(): void {
   if (!svgEl.value || !zoomLayerEl.value) return;
   installZoom();
-  void nextTick(() => fitToScreen(false));
-}
+  void nextTick(() => {
+    fitToScreen(false);
+    fittedWithContent = nodes.value.length > 0;
+  });
+}
+// A graph that arrives INTO a canvas fitted while it was empty is framed for
+// an extent that did not exist — commonly a quiet window, where the accepted
+// empty result renders first and the instances appear on a later round. The
+// element does not change, so the mount watcher never runs again and a larger
+// graph sits clipped.
+watch(
+  () => nodes.value.length,
+  (n) => {
+    if (n === 0 || fittedWithContent || !svgEl.value || !zoomBehaviour) return;
+    void nextTick(() => {
+      fitToScreen(false);
+      fittedWithContent = true;
+    });
+  },
+);
 // The <svg> lives behind a v-else and unmounts whenever a new pair's data
 // is in flight (the query key changes → data drops → "Reading data…"),
 // then remounts when it lands. d3.zoom listeners bind to the specific
@@ -451,16 +489,31 @@ function installZoomAndFit(): void {
 // leave drag-pan / wheel-zoom / dblclick-fit dead after the first pair
 // switch (the +/−/Fit buttons would still work, masking the bug).
 watch(svgEl, (el) => { if (el && zoomLayerEl.value) installZoomAndFit(); }, { flush: 'post' });
-// Re-fit when the graph shape changes while the element persists (e.g.
-// auto-refresh keeps the SVG mounted but the instance set shifts).
-watch(
-  () => `${nodes.value.length}|${visibleCalls.value.length}`,
-  () => { if (svgEl.value && zoomBehaviour) void nextTick(() => fitToScreen(false)); },
-);
+// Re-fit only when the PAIR changes — a different client/server pair is a
+// different graph. The previous rule refitted whenever the instance set
+// shifted, which the comment described as an auto-refresh case and which is
+// exactly the one that must not re-frame: an operator who zoomed into an edge
+// lost it every time an instance came or went.
+watch(predicateKey, () => {
+  fittedWithContent = false;
+  if (svgEl.value && zoomBehaviour) {
+    void nextTick(() => {
+      fitToScreen(false);
+      fittedWithContent = nodes.value.length > 0;
+    });
+  }
+});
 
 // ── Selection (edge → sidebar, node → popover). Reset on pair change.
 const selectedCallId = ref<string | null>(null);
 const popoverNodeId = ref<string | null>(null);
+// A predicate change is a different graph — the time range and the cold stage
+// are part of it, not just the instance pair — so a selection made against the
+// previous one does not carry over.
+watch(predicateKey, () => {
+  selectedCallId.value = null;
+  popoverNodeId.value = null;
+});
 function selectEdge(id: string): void {
   popoverNodeId.value = null;
   selectedCallId.value = selectedCallId.value === id ? null : id;
@@ -578,7 +631,17 @@ function diffText(d: number | null): string {
 }
 
 const showPickPrompt = computed(() => !enabled.value);
-const showLoading = computed(() => enabled.value && isFetching.value && nodes.value.length === 0);
+// "Reading data…" means there is nothing to draw for the CURRENT predicate —
+// not "a request is in flight". Gating on `isFetching && nodes.length === 0`
+// blanked the canvas on every refresh, because the node list emptied while the
+// round was out. `initialPending` is false as soon as a drawable graph exists,
+// so a refresh redraws over the previous picture instead of erasing it.
+const showLoading = computed(() => phase.value === 'loading');
+const showFailed = computed(() => phase.value === 'failed');
+// Names the pair being read, so a switch shows which relationship is coming.
+// From the picker's own ids, NOT from `data` — during a load there is no data
+// yet, and naming the pair is the whole point of saying it here.
+const loadingTarget = computed(() => `${nameOf(clientId.value)} → ${nameOf(serverId.value)}`);
 const isEmpty = computed(() => enabled.value && !isFetching.value && nodes.value.length === 0);
 
 function onKeyDown(e: KeyboardEvent): void {
@@ -640,7 +703,19 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown, true));
     <div class="imv-body" :class="{ 'no-selection': !selectedCall }">
       <div ref="containerEl" class="imv-canvas">
         <div v-if="showPickPrompt" class="imv-state">{{ t('Pick a client and server service to see their instance topology.') }}</div>
-        <div v-else-if="showLoading" class="imv-state">{{ t('Reading data…') }}</div>
+        <div v-else-if="showLoading" class="imv-state">{{ t('Loading relationship for “{target}”…', { target: loadingTarget }) }}</div>
+        <!-- A DISABLED layer is an administrator's answer, not a failure to
+             reach anything: offering Retry would invite the operator to keep
+             asking for something that will not change until someone re-enables
+             it. -->
+        <div v-else-if="blocked === 'layer-disabled'" class="imv-state">
+          {{ t('This layer’s template is disabled.') }}
+          {{ t('An administrator turned it off — re-enable it under Operate → Templates.') }}
+        </div>
+        <div v-else-if="showFailed" class="imv-state">
+          {{ t('Could not load the instance relationship.') }}
+          <button class="sw-btn small" type="button" @click="refetch()">{{ t('Retry') }}</button>
+        </div>
         <div v-else-if="isEmpty" class="imv-state">{{ t('No instance topology in this window.') }}</div>
         <template v-else>
           <svg ref="svgEl" class="imv-svg" width="100%" height="100%">
diff --git a/apps/ui/src/layer/service-map/LayerServiceMapView.vue b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
index cf927c9d..b55dc7c1 100644
--- a/apps/ui/src/layer/service-map/LayerServiceMapView.vue
+++ b/apps/ui/src/layer/service-map/LayerServiceMapView.vue
@@ -80,6 +80,7 @@ import TopologyFocusPicker from '@/layer/service-map/TopologyFocusPicker.vue';
 import TopologyNodeFilter from '@/layer/service-map/TopologyNodeFilter.vue';
 import TopologyDetailPanels from '@/layer/service-map/TopologyDetailPanels.vue';
 import { useTopologyCanvas } from '@/layer/service-map/useTopologyCanvas';
+import { useAutoRefreshStore } from '@/controls/autoRefresh';
 import {
   clusterBuckets as buildClusterBuckets,
   clusterRects as buildClusterRects,
@@ -132,6 +133,7 @@ const layerKey = computed(() =>
 const embedded = computed(() => Boolean(props.embedded));
 
 const { layers } = useLayers();
+const auto = useAutoRefreshStore();
 const layer = computed<LayerDef | null>(
   // Case-insensitive: layer defs key on the uppercase OAP enum, but layerKey can
   // arrive lowercased (e.g. the AI chat block passes spec.layer.toLowerCase()).
@@ -199,7 +201,8 @@ function truncateLabel(s: string, n: number): string {
 const depth = ref<number>(props.focusDepth ?? 2);
 const focusWindowMinutes = computed<number | null>(() => props.focusWindowMinutes ?? null);
 const replayDataRef = computed<TopologyResponse | null>(() => props.replayData ?? null);
-const { nodes, calls, isLoading, isFetching, data, refetch } = useLayerTopology(
+const { nodes, calls, isFetching, data, acceptedSnapshot, refetch, phase, predicateKey } =
+  useLayerTopology(
   layerKey,
   focusServices,
   depth,
@@ -207,9 +210,23 @@ const { nodes, calls, isLoading, isFetching, data, refetch } = useLayerTopology(
   replayDataRef,
 );
 const reachable = computed(() => data.value?.reachable !== false);
+/**
+ * WHY it is empty, when the answer is not "OAP could not be read".
+ *
+ * A disabled template and an unreachable store both arrive as an unreachable
+ * response, and telling an operator to check OAP when an administrator turned
+ * the layer off sends them to the wrong place entirely.
+ */
+const blocked = computed(() => data.value?.blocked ?? null);
 const errorText = computed(() => data.value?.error ?? null);
-const tooLarge = computed(() => data.value?.tooLarge ?? null);
-const metricsPartial = computed(() => data.value?.metricsPartial ?? null);
+// Fields that DESCRIBE THE DRAWN GRAPH come from the snapshot; only the fields
+// that describe the latest ATTEMPT (reachable / blocked / error) come from
+// `data`. Reading them all from `data` mixed the two: after a failed round the
+// nodes were still the snapshot's while the config, size warning and partial-
+// metric notice came from the empty failure — so a drawn graph lost its metric
+// definitions and its warnings the moment a refresh could not be read.
+const tooLarge = computed(() => acceptedSnapshot.value?.tooLarge ?? null);
+const metricsPartial = computed(() => acceptedSnapshot.value?.metricsPartial ?? null);
 
 // ── Node visibility filter. Buckets store EXCLUSIONS (a node is hidden
 // when its layer is in the set), so a freshly-appearing layer defaults
@@ -242,7 +259,7 @@ function resetFilter(): void {
 // ── Config from response (operator-edited layer JSON). Falls back to an
 // empty config when the BFF hasn't responded yet — the renderer degrades
 // gracefully to neutral colours / no number.
-const cfg = computed(() => data.value?.config ?? {
+const cfg = computed(() => acceptedSnapshot.value?.config ?? {
   nodeMetrics: [] as TopologyMetricDef[],
   linkServerMetrics: [] as TopologyMetricDef[],
   linkClientMetrics: [] as TopologyMetricDef[],
@@ -380,16 +397,40 @@ const layoutNodes = computed<LayoutNode[]>(() => {
 });
 
 // Drag overrides — populated by d3.drag on each node. Once a user drops a
-// node anywhere on the canvas, its (cx, cy) is pinned here and the layout
-// uses the override instead of the column layout. The map is keyed by
-// service id so it survives layer re-render as long as the same services
-// come back. Cleared whenever the underlying node set changes
-// meaningfully (different layer / refresh blowing away the topology).
+// node anywhere on the canvas, its (cx, cy) is pinned here and the layout uses
+// the override instead of the column layout. Keyed by service id.
+//
+// TWO rules decide a pin's life, and neither is "the node set changed":
+//
+//  1. A different PREDICATE is a different graph — another layer, another
+//     service, another window — so every pin goes. This is the cascade-clear.
+//  2. A node that has LEFT the graph loses its pin. Pruned here rather than
+//     merely ignored at read time: an ignored override is retained forever and
+//     RESURRECTS if that service ever comes back, dropping it wherever it sat
+//     in a graph the operator may no longer remember.
+//
+// What must NOT clear them is a refresh. The previous rule watched the sorted
+// node-id signature, which emptied on every tick while the round was out, so
+// every pin was lost about twice a minute.
 const dragOverrides = ref<Map<string, Pos>>(new Map());
+// Placements go on a predicate change; the selection goes with them, in the
+// consolidated watcher below — it has to sit after the selection refs are
+// declared.
+watch(predicateKey, () => { dragOverrides.value = new Map(); });
 watch(
-  () => layoutNodes.value.map((n) => n.id).sort().join('|'),
-  (curr, prev) => {
-    if (prev !== undefined && curr !== prev) dragOverrides.value = new Map();
+  // The RAW graph, not the laid-out/filtered nodes: hiding a node behind a
+  // display filter must not throw away where the operator put it.
+  () => nodes.value.map((n) => n.id),
+  (ids) => {
+    if (dragOverrides.value.size === 0) return;
+    const live = new Set(ids);
+    let dropped = false;
+    const kept = new Map<string, Pos>();
+    for (const [id, pos] of dragOverrides.value) {
... 2763 lines suppressed ...