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/05/24 13:28:28 UTC

(skywalking-horizon-ui) branch main updated: feat: BanyanDB cold-stage query + lifecycle TTL page + picker fixes (#13)

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 78cb984  feat: BanyanDB cold-stage query + lifecycle TTL page + picker fixes (#13)
78cb984 is described below

commit 78cb9841bf588c7608f10a89375d12f1e239086e
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun May 24 21:28:23 2026 +0800

    feat: BanyanDB cold-stage query + lifecycle TTL page + picker fixes (#13)
    
    Cold-stage query
    - Topbar 'Cold' pill (BanyanDB-only) swaps every page to read from
      the cold lifecycle stage. Sticky per browser; flipping invalidates
      the visible queries so subscribers refetch immediately.
    - Yellow trap banner under the topbar when the pill is on AND the
      current time range is within hot+warm (cold returns nothing there).
    - Trace lookup from a log row carries the row's timestamp through
      the popout so OAP widens its trace search past the default 1-day
      window — pairs with the Cold pill so cold-tier trace IDs resolve
      from cold-era log rows.
    
    Data retention page (/operate/ttl)
    - Per-data-class lifecycle bar: one row per class (Normal, Trace,
      Log, Metadata, Minute metric, …) with Hot+Warm and (when present)
      Cold segments. Widths proportional to total retention across all
      rows. Rows collapse to 'All records (5)' / 'All metrics (4)' when
      every class in a category shares the same TTL pair.
    - Page branches sharply by backend: BanyanDB shows the full lifecycle
      bar + stage vocabulary; non-BanyanDB renders a single Retention
      pane with per-class values and skips the stage vocabulary.
    - Footer note names the wire-level truth (hot+warm is collapsed on
      the wire, segment-migration overlaps near boundaries, property
      omitted).
    
    Overview widgets follow the global time picker
    - The Services Dashboard (overview metric / KPI / table widgets)
      now honors the topbar time picker — previously landing + topology
      were hardcoded to the last 60 minutes regardless of picker.
    - Layer dashboard header KPIs follow the picker too (was showing
      recent numbers while body honored the picker).
    - Active alarms widget title shows the actual window (· last 10m)
      instead of a fixed 'last 60m'; empty-state uses the same value.
    
    Time picker
    - Custom range seeds from the last applied range instead of resetting
      to half-max-ending-now. Re-opening the picker on a custom range
      auto-expands the Custom form on the matching precision tab.
    - Locale-bleed fixed on the alarms-page custom-range stamp and the
      log row date column (was '5月08日' on zh-CN browsers; now uniform
      MM-DD).
    
    BFF
    - New apps/bff/src/util/window.ts (Window/TimeStep/fmtForStep/
      windowFromRange/defaultMinuteWindow) — shared by dashboard /
      landing / topology / trace-by-id so per-route format drift can't
      happen.
    - New apps/bff/src/util/duration.ts — onRequest hook reads the
      cold-stage header, withColdStage() splices coldStage:true into
      Duration variables.
    - New apps/bff/src/logic/oap/backend.ts — classifies the connected
      OAP from its TTL response (banyandb / other / unknown), exposed
      on /api/oap/info.
    - /api/oap/ttl now returns backend + stages.{hot,cold} derived view.
    - POST /api/layer/:key/landing + GET /api/layer/:key/topology now
      accept step/startMs/endMs.
    - GET /api/trace/:traceId accepts startMs/endMs/step and forwards
      queryTrace(duration:…) so older trace IDs resolve.
    
    Polish
    - Sentence-case fixes ('DSL management', 'Metrics inspect') across
      sidebar, roles, cluster status, DSL warning, catalog kicker.
    - Public-demo references removed from setup docs.
---
 CHANGELOG.md                                       |  75 ++-
 apps/bff/src/http/query/alarms.ts                  |   9 +-
 apps/bff/src/http/query/dashboard.ts               |  67 +--
 apps/bff/src/http/query/ebpf.ts                    |   9 +-
 apps/bff/src/http/query/endpoint-dependency.ts     |  15 +-
 apps/bff/src/http/query/endpoint.ts                |   3 +-
 apps/bff/src/http/query/info.ts                    |   5 +-
 apps/bff/src/http/query/instance.ts                |   3 +-
 apps/bff/src/http/query/landing.ts                 |  62 ++-
 apps/bff/src/http/query/log.ts                     |   5 +-
 apps/bff/src/http/query/topology.ts                |  59 +-
 apps/bff/src/http/query/trace.ts                   |  63 ++-
 apps/bff/src/http/query/ttl.ts                     |  64 ++-
 apps/bff/src/logic/oap/backend.ts                  | 124 +++++
 apps/bff/src/server.ts                             |   7 +
 apps/bff/src/util/duration.ts                      |  80 +++
 apps/bff/src/util/window.ts                        |  83 +++
 apps/ui/src/api/client.ts                          |   5 +
 apps/ui/src/api/scopes/layer.ts                    |  12 +-
 apps/ui/src/api/scopes/trace.ts                    |  12 +
 apps/ui/src/components/icons/Icon.vue              |   7 +-
 apps/ui/src/components/primitives/CatalogNav.vue   |   2 +-
 apps/ui/src/controls/coldStage.ts                  | 109 ++++
 apps/ui/src/features/admin/roles/RolesView.vue     |   4 +-
 apps/ui/src/features/alarms/AlarmsView.vue         |  11 +-
 .../features/operate/cluster/ClusterStatusView.vue |   2 +-
 .../ui/src/features/operate/dsl/DslCatalogView.vue |   2 +-
 apps/ui/src/features/operate/ttl/TtlView.vue       | 591 ++++++++++++++++++---
 apps/ui/src/layer/LayerShell.vue                   |  16 +-
 apps/ui/src/layer/logs/LayerLogsView.vue           |  21 +-
 apps/ui/src/layer/service-map/useLayerTopology.ts  |  27 +-
 apps/ui/src/layer/traces/TracePopout.vue           |   9 +-
 apps/ui/src/layer/traces/useLayerTraces.ts         |  33 +-
 apps/ui/src/layer/traces/useTracePopout.ts         |  38 +-
 .../ui/src/render/overview/useOverviewDashboard.ts |  18 +-
 apps/ui/src/render/widgets/AlarmsWidget.vue        |  23 +-
 apps/ui/src/shell/AppShell.vue                     |   7 +
 apps/ui/src/shell/AppSidebar.vue                   |   4 +-
 apps/ui/src/shell/AppTopbar.vue                    | 115 +++-
 apps/ui/src/shell/ColdStageTrapBanner.vue          | 158 ++++++
 apps/ui/src/shell/useOapInfo.ts                    |   7 +
 docs/README.md                                     |  15 -
 docs/setup/oap.md                                  |   2 +-
 packages/api-client/src/index.ts                   |   3 +-
 packages/api-client/src/oap-info.ts                |  11 +
 packages/api-client/src/oap-ops.ts                 |  58 +-
 46 files changed, 1779 insertions(+), 276 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d0cc63..8ae2648 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,80 @@ packages) plus the BFF's `HORIZON_VERSION` default.
 
 ## 0.6.0
 
-### Dashboard authoring — browser-local drafts + preview
+### BanyanDB cold-stage query
+
+The cold lifecycle stage is now reachable from the UI on BanyanDB
+deployments — operators can query data that has aged past the
+hot + warm window without leaving the page.
+
+- **Topbar `Cold` pill** appears only when the connected OAP is BanyanDB.
+  Toggling it switches every page to read from the cold stage instead
+  of hot + warm — it replaces the read, it does NOT union the two stages,
+  so the pill label flips to `Cold only` while on. The choice is sticky
+  per browser and re-runs every visible query so what you see matches the
+  new mode immediately.
+- **Cold-trap banner.** When the pill is on AND the current time range is
+  within the hot + warm window, a yellow strip appears under the topbar:
+  *"Cold-only read is active — your time range is within the last N d
+  (hot + warm), where the cold stage returns nothing."* A one-click
+  *Turn Cold off* sits on the right.
+- **Trace lookup from a log row** now passes the row's timestamp through
+  the popout so the trace lookup spans a window around that timestamp
+  instead of OAP's default last-1-day search — paired with the Cold pill,
+  a trace that lives in cold resolves from a cold-era log row instead of
+  silently failing to load.
+
+### Data retention page
+
+- **Per-data-class data lifecycle bar.** One row per data class
+  (Normal / Trace / Zipkin trace / Log / Browser error log / Metadata /
+  Minute metric / Hour metric / Day metric) with proportional Hot+Warm
+  and (when configured) Cold segments. Widths are proportional to total
+  retention across all rows, so a class retained longer visibly stretches
+  further than its peers.
+- When every class in a category shares the same TTL pair, the rows
+  collapse to `All records (5)` / `All metrics (4)` — the page never
+  renders nine identical bars.
+- The page **branches sharply by backend.** BanyanDB shows the full
+  lifecycle bar + stage vocabulary; on any other backend, the page
+  renders a single `Retention` pane with per-class values and skips the
+  stage vocabulary entirely.
+- A footer note names the wire-level truth: OAP's TTL response collapses
+  hot + warm into one number per class, BanyanDB migrates between stages
+  in segments so records near a boundary may briefly exist in both, and
+  property data is omitted (forever-retained, no TTL reported).
+
+### Time picker
+
+- **Custom range seeds from the last applied range** when you re-open the
+  picker, instead of resetting to "half the max ending now". Reopening
+  also auto-expands the Custom form on the matching precision tab when
+  the current range is custom.
+- Locale-bleed fix on the alarms page custom-range stamp and the log row
+  date column (was rendering `5月08日` on zh-CN browsers; now uniform
+  `MM-DD`).
+
+### Overview widgets follow the global time picker
+
+- The **Services Dashboard** (and any overview using metric / KPI / table
+  widgets) now honors the topbar time picker. Previously the per-layer
+  landing and topology routes were hardcoded to the last 60 minutes, so
+  picking 12 days back kept showing recent numbers; now picker + Cold pill
+  flow end-to-end. The layer dashboard's header KPIs follow the picker too
+  (was showing live numbers while the body honored the picker).
+- The **Active alarms** widget title now shows the actual window
+  (e.g. `· last 10m`) and the empty-state copy uses the same value
+  instead of a hardcoded "last 60m".
+
+### Polish
+
+- Sentence-case fixes on a couple of leftover Title-Case labels
+  (`DSL management`, `Metrics inspect`) so the menu and roles tables read
+  consistently; acronyms (DSL / OAP / MAL / LAL / OAL) stay uppercase.
+- Public-demo (`demo.skywalking.apache.org`) references removed from
+  setup docs — the demo doesn't accept anonymous traffic.
+
+### Dashboard authoring
 
 The Layer dashboards and Overview templates admin pages now share one
 editing model where your work-in-progress lives in your browser, and the
diff --git a/apps/bff/src/http/query/alarms.ts b/apps/bff/src/http/query/alarms.ts
index 033775b..2bfc22b 100644
--- a/apps/bff/src/http/query/alarms.ts
+++ b/apps/bff/src/http/query/alarms.ts
@@ -54,6 +54,7 @@ import type { SessionStore } from '../../user/sessions.js';
 import { badRequest } from '../../errors.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
 import { getOapCapabilities } from '../../logic/oap/capabilities.js';
+import { withColdStage } from '../../util/duration.js';
 import type { ServiceLayerCatalog } from '../../logic/services/service-layer-catalog.js';
 
 export interface AlarmsQueryRouteDeps {
@@ -434,7 +435,7 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
          * alarms, which looked like "the per-layer filter doesn't
          * work". */
         const condition: Record<string, unknown> = {
-          duration: { start, end, step: 'SECOND' },
+          duration: withColdStage(req, { start, end, step: 'SECOND' }),
           paging: { pageNum: q.pageNum, pageSize: q.pageSize },
         };
         if (q.keyword) condition.keyword = q.keyword;
@@ -451,7 +452,7 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
          * URL), the BFF silently ignores them rather than 400-ing,
          * matching the spirit of the spec's "drop fake filters". */
         const variables: Record<string, unknown> = {
-          duration: { start, end, step: 'SECOND' },
+          duration: withColdStage(req, { start, end, step: 'SECOND' }),
           paging: { pageNum: q.pageNum, pageSize: q.pageSize },
         };
         if (q.scope) variables.scope = q.scope;
@@ -505,7 +506,7 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
       try {
         if (caps.queryAlarms) {
           const condition = {
-            duration: { start, end, step: 'SECOND' },
+            duration: withColdStage(req, { start, end, step: 'SECOND' }),
             paging: { pageNum: 1, pageSize: COUNT_FETCH_CAP },
           };
           const raw = await graphqlPost<QueryAlarmsRaw>(opts, COUNT_QUERY_ALARMS_QUERY, { condition });
@@ -516,7 +517,7 @@ export function registerAlarmsQueryRoutes(app: FastifyInstance, deps: AlarmsQuer
           }));
         } else {
           const variables = {
-            duration: { start, end, step: 'SECOND' },
+            duration: withColdStage(req, { start, end, step: 'SECOND' }),
             paging: { pageNum: 1, pageSize: COUNT_FETCH_CAP },
           };
           const raw = await graphqlPost<GetAlarmRaw>(opts, COUNT_GET_ALARM_QUERY, variables);
diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts
index 5167b46..9cd6046 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -43,6 +43,14 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
+import {
+  defaultMinuteWindow,
+  fmtForStep,
+  windowFromRange,
+  type TimeStep,
+  type Window,
+} from '../../util/window.js';
 import {
   getLayerTemplate,
   widgetsForScope,
@@ -183,50 +191,13 @@ const FIND_FIRST_ENDPOINT = /* GraphQL */ `
 `;
 
 const DEFAULT_WINDOW_MIN = 60;
-
-export type TimeStep = 'MINUTE' | 'HOUR' | 'DAY';
-
-export interface Window {
-  start: string;
-  end: string;
-  step: TimeStep;
-}
-function pad(n: number): string {
-  return String(n).padStart(2, '0');
-}
-function fmtMinute(d: Date): string {
-  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}`;
-}
-function fmtHour(d: Date): string {
-  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}`;
-}
-function fmtDay(d: Date): string {
-  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
-}
-/** Format a Date for OAP per the step. OAP's `verifyDateTimeString`
- *  rejects a string whose precision doesn't match the Duration.step. */
-export function fmtForStep(step: TimeStep, d: Date): string {
-  if (step === 'DAY') return fmtDay(d);
-  if (step === 'HOUR') return fmtHour(d);
-  return fmtMinute(d);
-}
 function defaultWindow(): Window {
-  const end = new Date();
-  end.setUTCSeconds(0, 0);
-  const start = new Date(end.getTime() - DEFAULT_WINDOW_MIN * 60_000);
-  return { start: fmtMinute(start), end: fmtMinute(end), step: 'MINUTE' };
-}
-/** Build the OAP window from the SPA-supplied range. All three inputs
- *  must be present; returns null otherwise so the caller can fall back
- *  to {@link defaultWindow}. */
-function windowFromRange(step: TimeStep, startMs: number, endMs: number): Window | null {
-  if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return null;
-  return {
-    start: fmtForStep(step, new Date(startMs)),
-    end: fmtForStep(step, new Date(endMs)),
-    step,
-  };
+  return defaultMinuteWindow(DEFAULT_WINDOW_MIN);
 }
+// Re-export so the existing `./dashboard.js` consumers (the test file
+// today; future fragment helpers that import Window from here) don't
+// need to know the helpers moved into the shared util.
+export { fmtForStep, type TimeStep, type Window };
 
 /** Build one aliased `execExpression` GraphQL fragment for a single
  *  widget expression. The entity scope flips based on opts:
@@ -249,6 +220,10 @@ export function buildFragment(
     layerScope?: boolean;
     serviceInstanceName?: string | null;
     endpointName?: string | null;
+    /** When true, splice `coldStage: true` into the Duration literal.
+     *  OAP ignores it for non-BanyanDB backends, so callers can pass it
+     *  unconditionally when the request asked for cold-stage data. */
+    coldStage?: boolean;
   } = {},
 ): string {
   // We fetch metric.labels (for multi-series Line widgets — relabels()
@@ -275,11 +250,12 @@ export function buildFragment(
   } else {
     entity = `{ scope: Service, serviceName: ${JSON.stringify(serviceName)}, normal: ${normal ? 'true' : 'false'} }`;
   }
+  const coldFrag = opts.coldStage ? ', coldStage: true' : '';
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(expression)},\n` +
     `      entity: ${entity},\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step} }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
     `    ) {\n` +
     `      type error\n` +
     `      results {\n` +
@@ -535,7 +511,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
             LIST_FIRST_INSTANCE,
             {
               serviceId,
-              duration: { start: window.start, end: window.end, step: window.step },
+              duration: withColdStage(req, { start: window.start, end: window.end, step: window.step }),
             },
           );
           selectedInstance = data.instances?.[0]?.name ?? null;
@@ -550,7 +526,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
             FIND_FIRST_ENDPOINT,
             {
               serviceId,
-              duration: { start: window.start, end: window.end, step: window.step },
+              duration: withColdStage(req, { start: window.start, end: window.end, step: window.step }),
             },
           );
           selectedEndpoint = data.endpoints?.[0]?.name ?? null;
@@ -599,6 +575,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
                       widget.layerScope !== true && scopeHonorsInstance ? selectedInstance : null,
                     endpointName:
                       widget.layerScope !== true && scopeHonorsEndpoint ? selectedEndpoint : null,
+                    coldStage: !!req.coldStage,
                   }),
                 );
               });
diff --git a/apps/bff/src/http/query/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
index 57162f7..00a3830 100644
--- a/apps/bff/src/http/query/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -52,6 +52,7 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
 import { getLayerTemplate, processTopologyConfigFor } from '../../logic/layers/loader.js';
 
 export interface EBPFRouteDeps {
@@ -252,7 +253,9 @@ function processRelationFragment(
   src: ProcessRelationEndpointRef,
   dst: ProcessRelationEndpointRef,
   w: { start: string; end: string },
+  coldStage: boolean,
 ): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(expr)},\n` +
@@ -265,7 +268,7 @@ function processRelationFragment(
     ` destNormal: ${dst.normal === false ? 'false' : 'true'},` +
     ` destServiceInstanceName: ${JSON.stringify(dst.serviceInstanceName)},` +
     ` destProcessName: ${JSON.stringify(dst.processName)} },\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE${coldFrag} }\n` +
     `    ) { error results { values { value } } }`
   );
 }
@@ -460,7 +463,7 @@ export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): v
           topology: { nodes: ProcessTopologyResponse['nodes']; calls: ProcessTopologyResponse['calls'] };
         }>(opts, GET_PROCESS_TOPOLOGY, {
           serviceInstanceId: instance,
-          duration: { start: fmt(start), end: fmt(end), step: 'MINUTE' },
+          duration: withColdStage(req, { start: fmt(start), end: fmt(end), step: 'MINUTE' }),
         });
         payload.nodes = data.topology?.nodes ?? [];
         payload.calls = data.topology?.calls ?? [];
@@ -607,7 +610,7 @@ export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): v
         list.forEach((m, i) => {
           const alias = `${side}_${i}`;
           aliasMap.set(alias, { side, metric: m });
-          fragments.push(processRelationFragment(alias, m.mqe, src, dst, w));
+          fragments.push(processRelationFragment(alias, m.mqe, src, dst, w, !!req.coldStage));
         });
       };
       push('client', cfg.edgeClientMetrics);
diff --git a/apps/bff/src/http/query/endpoint-dependency.ts b/apps/bff/src/http/query/endpoint-dependency.ts
index 312d29f..fa10335 100644
--- a/apps/bff/src/http/query/endpoint-dependency.ts
+++ b/apps/bff/src/http/query/endpoint-dependency.ts
@@ -41,6 +41,7 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
 import { endpointDependencyConfigFor, getLayerTemplate } from '../../logic/layers/loader.js';
 
 export interface EndpointDependencyRouteDeps {
@@ -127,7 +128,9 @@ function endpointFragment(
   endpointName: string,
   normal: boolean,
   w: { start: string; end: string },
+  coldStage: boolean,
 ): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(m.mqe)},\n` +
@@ -135,7 +138,7 @@ function endpointFragment(
     ` serviceName: ${JSON.stringify(serviceName)},` +
     ` endpointName: ${JSON.stringify(endpointName)},` +
     ` normal: ${normal ? 'true' : 'false'} },\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -155,7 +158,9 @@ function endpointRelationFragment(
   destEndpointName: string,
   destNormal: boolean,
   w: { start: string; end: string },
+  coldStage: boolean,
 ): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(m.mqe)},\n` +
@@ -166,7 +171,7 @@ function endpointRelationFragment(
     ` destServiceName: ${JSON.stringify(destServiceName)},` +
     ` destEndpointName: ${JSON.stringify(destEndpointName)},` +
     ` destNormal: ${destNormal ? 'true' : 'false'} },\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -268,7 +273,8 @@ export function registerEndpointDependencyRoute(
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const window = defaultWindow();
       const oapLayer = layerKey.toUpperCase();
-      const durationVar = { start: window.start, end: window.end, step: 'MINUTE' };
+      const durationVar = withColdStage(req, { start: window.start, end: window.end, step: 'MINUTE' });
+      const coldStage = !!req.coldStage;
 
       // ── Resolve service name → id.
       let serviceId = serviceArg;
@@ -370,7 +376,7 @@ export function registerEndpointDependencyRoute(
           epCfg.nodeMetrics.forEach((m, j) => {
             const alias = `e${i}_${j}`;
             aliasMap.set(alias, { nodeId: n.id, metric: m });
-            fragments.push(endpointFragment(alias, m, n.serviceName, n.name, useNormal, window));
+            fragments.push(endpointFragment(alias, m, n.serviceName, n.name, useNormal, window, coldStage));
           });
         });
         const CHUNK = 150;
@@ -442,6 +448,7 @@ export function registerEndpointDependencyRoute(
                 dst.name,
                 dstNormal,
                 window,
+                coldStage,
               ),
             );
           });
diff --git a/apps/bff/src/http/query/endpoint.ts b/apps/bff/src/http/query/endpoint.ts
index 354c1d8..9df16d0 100644
--- a/apps/bff/src/http/query/endpoint.ts
+++ b/apps/bff/src/http/query/endpoint.ts
@@ -39,6 +39,7 @@ import type { SessionStore } from '../../user/sessions.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
 
 export interface EndpointRouteDeps {
   config: ConfigSource;
@@ -163,7 +164,7 @@ export function registerEndpointRoute(app: FastifyInstance, deps: EndpointRouteD
           serviceId,
           keyword,
           limit,
-          duration: { start: window.start, end: window.end, step: 'MINUTE' },
+          duration: withColdStage(req, { start: window.start, end: window.end, step: 'MINUTE' }),
         });
         return reply.send({
           layer: layerKey,
diff --git a/apps/bff/src/http/query/info.ts b/apps/bff/src/http/query/info.ts
index 90f6e40..ce24104 100644
--- a/apps/bff/src/http/query/info.ts
+++ b/apps/bff/src/http/query/info.ts
@@ -22,6 +22,7 @@ import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { basicAuthHeader, buildOapOpts, graphqlPost } from '../../client/graphql.js';
 import { getOapCapabilities } from '../../logic/oap/capabilities.js';
+import { getOapBackend } from '../../logic/oap/backend.js';
 
 /**
  * One round-trip combining `version`, `getTimeInfo`, and `checkHealth`.
@@ -106,9 +107,10 @@ export function registerOapInfoRoute(app: FastifyInstance, deps: InfoRouteDeps):
        * round-trip latency to every poll without changing semantics.
        * The probe is internally cached for 5 min so the wire traffic
        * is one-off per OAP restart, not per call. */
-      const [raw, capabilities, zipkin] = await Promise.all([
+      const [raw, capabilities, backend, zipkin] = await Promise.all([
         graphqlPost<InfoRaw>(buildOapOpts(cfg, deps.fetch), INFO_QUERY),
         getOapCapabilities(cfg, deps.fetch),
+        getOapBackend(cfg, deps.fetch),
         zipkinP,
       ]);
       const body: OapInfo = {
@@ -120,6 +122,7 @@ export function registerOapInfoRoute(app: FastifyInstance, deps: InfoRouteDeps):
         healthScore: raw.health?.score ?? undefined,
         healthDetails: raw.health?.details ?? undefined,
         capabilities,
+        backend,
         zipkinUrl,
         zipkinReachable: zipkin.reachable,
         zipkinError: zipkin.error,
diff --git a/apps/bff/src/http/query/instance.ts b/apps/bff/src/http/query/instance.ts
index 699429d..10b43b6 100644
--- a/apps/bff/src/http/query/instance.ts
+++ b/apps/bff/src/http/query/instance.ts
@@ -37,6 +37,7 @@ import type { SessionStore } from '../../user/sessions.js';
 import type { FetchLike } from '@skywalking-horizon-ui/api-client';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
 
 export interface InstanceRouteDeps {
   config: ConfigSource;
@@ -171,7 +172,7 @@ export function registerInstanceRoute(app: FastifyInstance, deps: InstanceRouteD
       try {
         const data = await graphqlPost<{ instances: OapInstance[] }>(opts, LIST_INSTANCES, {
           serviceId,
-          duration: { start: window.start, end: window.end, step: 'MINUTE' },
+          duration: withColdStage(req, { start: window.start, end: window.end, step: 'MINUTE' }),
         });
         const rows: InstanceRow[] = (data.instances ?? []).map((i) => ({
           id: i.id,
diff --git a/apps/bff/src/http/query/landing.ts b/apps/bff/src/http/query/landing.ts
index 2f25c85..ea87817 100644
--- a/apps/bff/src/http/query/landing.ts
+++ b/apps/bff/src/http/query/landing.ts
@@ -50,6 +50,7 @@ import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
 import { expressionForServiceMetricSeries } from '../../util/mqe-catalog.js';
+import { defaultMinuteWindow, windowFromRange, type Window } from '../../util/window.js';
 
 export interface LandingRouteDeps {
   config: ConfigSource;
@@ -122,30 +123,18 @@ const bodySchema = z.object({
     .object({ metric: z.string().min(1), height: z.number().int().positive() })
     .optional(),
   throughput: throughputSchema.optional(),
+  // Topbar time picker — same triplet shape the dashboard route accepts.
+  // When all three are present the BFF queries OAP at the requested
+  // window/precision; otherwise it falls back to the last-hour MINUTE
+  // window (DEFAULT_WINDOW_MIN). The Overview composable
+  // (apps/ui/src/render/overview/useOverviewDashboard.ts) forwards
+  // these so flipping the topbar Time / Cold pills actually changes
+  // what the overview KPIs see.
+  step: z.enum(['MINUTE', 'HOUR', 'DAY']).optional(),
+  startMs: z.number().int().positive().optional(),
+  endMs: z.number().int().positive().optional(),
 });
 
-interface Window {
-  start: string;
-  end: string;
-  step: 'MINUTE';
-}
-
-function fmtMinute(d: Date): string {
-  const yyyy = d.getUTCFullYear();
-  const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
-  const dd = String(d.getUTCDate()).padStart(2, '0');
-  const hh = String(d.getUTCHours()).padStart(2, '0');
-  const mi = String(d.getUTCMinutes()).padStart(2, '0');
-  return `${yyyy}-${mm}-${dd} ${hh}${mi}`;
-}
-
-function defaultWindow(): Window {
-  const end = new Date();
-  end.setUTCSeconds(0, 0);
-  const start = new Date(end.getTime() - DEFAULT_WINDOW_MIN * 60_000);
-  return { start: fmtMinute(start), end: fmtMinute(end), step: 'MINUTE' };
-}
-
 /**
  * Pick the time-series expression to fire for `(metric, layer)`. We
  * always go through the series variant (`avg(...)` stripped) so OAP
@@ -242,12 +231,13 @@ interface MqeRequest {
   normal: boolean;
 }
 
-function buildMqeFragment(aliasName: string, req: MqeRequest, w: Window): string {
+function buildMqeFragment(aliasName: string, m: MqeRequest, w: Window, coldStage: boolean): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
   return (
     `${aliasName}: execExpression(\n` +
-    `      expression: ${JSON.stringify(req.expression)},\n` +
-    `      entity: { scope: Service, serviceName: ${JSON.stringify(req.serviceName)}, normal: ${req.normal ? 'true' : 'false'} },\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `      expression: ${JSON.stringify(m.expression)},\n` +
+    `      entity: { scope: Service, serviceName: ${JSON.stringify(m.serviceName)}, normal: ${m.normal ? 'true' : 'false'} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -271,7 +261,13 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
       const oapLayer = layerKey.toUpperCase();
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const window = defaultWindow();
+      // 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).
+      const window =
+        cfg.step && cfg.startMs && cfg.endMs
+          ? windowFromRange(cfg.step, cfg.startMs, cfg.endMs) ?? defaultMinuteWindow(DEFAULT_WINDOW_MIN)
+          : defaultMinuteWindow(DEFAULT_WINDOW_MIN);
 
       // Step 1 — service list.
       let services: ListServicesRow[];
@@ -288,7 +284,7 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
           topN: cfg.topN,
           orderBy: cfg.orderBy,
           generatedAt: Date.now(),
-          step: 'MINUTE',
+          step: window.step,
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
@@ -315,7 +311,7 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
           topN: cfg.topN,
           orderBy: cfg.orderBy,
           generatedAt: Date.now(),
-          step: 'MINUTE',
+          step: window.step,
           durationStart: window.start,
           durationEnd: window.end,
           rows: [],
@@ -332,6 +328,7 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
         expression: resolveMqe(c.metric, c.mqe, layerKey),
       }));
 
+      const coldStage = !!req.coldStage;
       const fragments: string[] = [];
       sampled.forEach((svc, sIdx) => {
         resolved.forEach(({ expression }, cIdx) => {
@@ -341,6 +338,7 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
               alias(sIdx, cIdx),
               { expression, serviceName: svc.value, normal: svc.normal !== false },
               window,
+              coldStage,
             ),
           );
         });
@@ -420,10 +418,10 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
           if (!svc) return;
           const r: MqeRequest = { expression: '', serviceName: svc.value, normal: svc.normal !== false };
           if (sparkExpr) {
-            sparkFragments.push(buildMqeFragment(`s${i}`, { ...r, expression: sparkExpr }, window));
+            sparkFragments.push(buildMqeFragment(`s${i}`, { ...r, expression: sparkExpr }, window, coldStage));
           }
           if (throughputExpr && throughputExpr !== sparkExpr) {
-            sparkFragments.push(buildMqeFragment(`t${i}`, { ...r, expression: throughputExpr }, window));
+            sparkFragments.push(buildMqeFragment(`t${i}`, { ...r, expression: throughputExpr }, window, coldStage));
           }
         });
         if (sparkFragments.length > 0) {
@@ -512,7 +510,7 @@ export function registerLandingRoute(app: FastifyInstance, deps: LandingRouteDep
         topN: cfg.topN,
         orderBy: cfg.orderBy,
         generatedAt: Date.now(),
-        step: 'MINUTE',
+        step: window.step,
         durationStart: window.start,
         durationEnd: window.end,
         rows: topRows,
diff --git a/apps/bff/src/http/query/log.ts b/apps/bff/src/http/query/log.ts
index 25c82e1..eb83c70 100644
--- a/apps/bff/src/http/query/log.ts
+++ b/apps/bff/src/http/query/log.ts
@@ -42,6 +42,7 @@ import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts, type GraphqlOptions } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
 
 export interface LogRouteDeps {
   config: ConfigSource;
@@ -194,7 +195,7 @@ export function registerLogRoute(app: FastifyInstance, deps: LogRouteDeps): void
           ? { keywordsOfContent: body.keywordsOfContent }
           : {}),
         ...(body.tags && body.tags.length > 0 ? { tags: body.tags } : {}),
-        queryDuration: { start: window.start, end: window.end, step: 'MINUTE' },
+        queryDuration: withColdStage(req, { start: window.start, end: window.end, step: 'MINUTE' }),
         paging: {
           pageNum: body.page ?? 1,
           pageSize: body.pageSize ?? 50,
@@ -286,7 +287,7 @@ export function registerLogRoute(app: FastifyInstance, deps: LogRouteDeps): void
         // Facet sample intentionally ignores level/tag filters so the
         // counts show the unfiltered distribution; the user picks a
         // level from the breakdown.
-        queryDuration: { start: window.start, end: window.end, step: 'MINUTE' },
+        queryDuration: withColdStage(req, { start: window.start, end: window.end, step: 'MINUTE' }),
         paging: { pageNum: 1, pageSize: sampleSize },
       };
 
diff --git a/apps/bff/src/http/query/topology.ts b/apps/bff/src/http/query/topology.ts
index 7309b53..d42a31d 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -46,6 +46,8 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { withColdStage } from '../../util/duration.js';
+import { defaultMinuteWindow, windowFromRange, type TimeStep, type Window } from '../../util/window.js';
 import { getLayerTemplate, topologyConfigFor } from '../../logic/layers/loader.js';
 
 export interface TopologyRouteDeps {
@@ -91,19 +93,6 @@ const LIST_SERVICES_FOR_RESOLVE = /* GraphQL */ `
 `;
 
 const DEFAULT_WINDOW_MIN = 60;
-function fmtMinute(d: Date): string {
-  const yyyy = d.getUTCFullYear();
-  const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
-  const dd = String(d.getUTCDate()).padStart(2, '0');
-  const HH = String(d.getUTCHours()).padStart(2, '0');
-  const MM = String(d.getUTCMinutes()).padStart(2, '0');
-  return `${yyyy}-${mm}-${dd} ${HH}${MM}`;
-}
-function defaultWindow(): { start: string; end: string } {
-  const end = new Date();
-  const start = new Date(end.getTime() - DEFAULT_WINDOW_MIN * 60_000);
-  return { start: fmtMinute(start), end: fmtMinute(end) };
-}
 
 interface MqeValueRow {
   value: string | number | null;
@@ -122,14 +111,16 @@ function nodeFragment(
   m: TopologyMetricDef,
   serviceName: string,
   normal: boolean,
-  w: { start: string; end: string },
+  w: Window,
+  coldStage: boolean,
 ): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(m.mqe)},\n` +
     `      entity: { scope: Service, serviceName: ${JSON.stringify(serviceName)},` +
     ` normal: ${normal ? 'true' : 'false'} },\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -151,8 +142,10 @@ function relationFragment(
   sourceNormal: boolean,
   destName: string,
   destNormal: boolean,
-  w: { start: string; end: string },
+  w: Window,
+  coldStage: boolean,
 ): string {
+  const coldFrag = coldStage ? ', coldStage: true' : '';
   return (
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(m.mqe)},\n` +
@@ -161,7 +154,7 @@ function relationFragment(
     ` normal: ${sourceNormal ? 'true' : 'false'},` +
     ` destServiceName: ${JSON.stringify(destName)},` +
     ` destNormal: ${destNormal ? 'true' : 'false'} },\n` +
-    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -260,7 +253,13 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
       if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
-      const q = req.query as { service?: string; depth?: string };
+      const q = req.query as {
+        service?: string;
+        depth?: string;
+        step?: string;
+        startMs?: string;
+        endMs?: string;
+      };
       const serviceArg = (q.service ?? '').trim();
       const depth = Math.max(1, Math.min(3, Number(q.depth) || 1));
 
@@ -269,9 +268,23 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
 
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const window = defaultWindow();
+      // 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
+      // service map both forward the picker so the topology metrics
+      // line up with whatever window the operator is looking at.
+      const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
+      const startMs = Number(q.startMs);
+      const endMs = Number(q.endMs);
+      const window: Window =
+        (stepArg === 'MINUTE' || stepArg === 'HOUR' || stepArg === 'DAY') &&
+        Number.isFinite(startMs) &&
+        Number.isFinite(endMs)
+          ? windowFromRange(stepArg, startMs, endMs) ?? defaultMinuteWindow(DEFAULT_WINDOW_MIN)
+          : defaultMinuteWindow(DEFAULT_WINDOW_MIN);
       const oapLayer = layerKey.toUpperCase();
-      const durationVar = { start: window.start, end: window.end, step: 'MINUTE' };
+      const durationVar = withColdStage(req, { start: window.start, end: window.end, step: window.step });
+      const coldStage = !!req.coldStage;
 
       // ── Resolve seed service ids.
       let seedIds: string[] = [];
@@ -386,7 +399,7 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
           topoCfg.nodeMetrics.forEach((m, j) => {
             const alias = `n${i}_${j}`;
             aliasMap.set(alias, { nodeId: n.id, metric: m });
-            fragments.push(nodeFragment(alias, m, n.name, normal, window));
+            fragments.push(nodeFragment(alias, m, n.name, normal, window, coldStage));
           });
         });
         const CHUNK = 150;
@@ -459,7 +472,7 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
             linkSrv.forEach((m, j) => {
               const alias = `s${i}_${j}`;
               aliasMap.set(alias, { callId: c.id, metric: m, side: 'server' });
-              fragments.push(relationFragment(alias, m, src.name, srcNormal, dst.name, dstNormal, window));
+              fragments.push(relationFragment(alias, m, src.name, srcNormal, dst.name, dstNormal, window, coldStage));
             });
           }
           // Client metrics live on the SOURCE — fetch only when the
@@ -468,7 +481,7 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
             linkCli.forEach((m, j) => {
               const alias = `c${i}_${j}`;
               aliasMap.set(alias, { callId: c.id, metric: m, side: 'client' });
-              fragments.push(relationFragment(alias, m, src.name, srcNormal, dst.name, dstNormal, window));
+              fragments.push(relationFragment(alias, m, src.name, srcNormal, dst.name, dstNormal, window, coldStage));
             });
           }
         });
diff --git a/apps/bff/src/http/query/trace.ts b/apps/bff/src/http/query/trace.ts
index 21da88d..50379ff 100644
--- a/apps/bff/src/http/query/trace.ts
+++ b/apps/bff/src/http/query/trace.ts
@@ -54,6 +54,8 @@ import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts, type GraphqlOptions } from '../../client/graphql.js';
 import { getLayerTemplate, tracesConfigFor } from '../../logic/layers/loader.js';
 import { detectTraceQueryApi } from '../../util/trace-protocol-cache.js';
+import { withColdStage } from '../../util/duration.js';
+import { windowFromRange } from '../../util/window.js';
 import { zipkinFetchTraces, zipkinFetchTraceById, summariseZipkinTrace } from '../../client/zipkin.js';
 
 export interface TraceRouteDeps {
@@ -176,9 +178,16 @@ const QUERY_TRACES = /* GraphQL */ `
   }
 `;
 
+/* `duration` is BanyanDB-only and optional. When the caller passes a
+ * window (start/end/step), OAP scopes the trace lookup to that window
+ * — necessary for IDs older than 1 day, since the default search is
+ * "last 1 day" only. Pair with `Duration.coldStage: true` (spliced by
+ * the `withColdStage` helper) for trace IDs whose data has migrated
+ * past hot+warm. Older OAP versions ignore the unknown variable;
+ * older non-BanyanDB backends ignore the Duration entirely. */
 const QUERY_TRACE_DETAIL = /* GraphQL */ `
-  query QueryTrace($traceId: ID!) {
-    trace: queryTrace(traceId: $traceId) {
+  query QueryTrace($traceId: ID!, $duration: Duration) {
+    trace: queryTrace(traceId: $traceId, duration: $duration) {
       spans {
         traceId
         segmentId
@@ -233,7 +242,12 @@ async function resolveServiceId(
   );
 }
 
-function buildTraceCondition(body: TraceListBody, resolvedServiceId: string | null, w: { start: string; end: string }) {
+function buildTraceCondition(
+  body: TraceListBody,
+  resolvedServiceId: string | null,
+  w: { start: string; end: string },
+  coldStage: boolean,
+) {
   return {
     ...(resolvedServiceId ? { serviceId: resolvedServiceId } : {}),
     ...(body.instanceId ? { serviceInstanceId: body.instanceId } : {}),
@@ -242,7 +256,12 @@ function buildTraceCondition(body: TraceListBody, resolvedServiceId: string | nu
     ...(body.tags && body.tags.length > 0 ? { tags: body.tags } : {}),
     ...(typeof body.minTraceDuration === 'number' ? { minTraceDuration: body.minTraceDuration } : {}),
     ...(typeof body.maxTraceDuration === 'number' ? { maxTraceDuration: body.maxTraceDuration } : {}),
-    queryDuration: { start: w.start, end: w.end, step: 'MINUTE' },
+    queryDuration: {
+      start: w.start,
+      end: w.end,
+      step: 'MINUTE',
+      ...(coldStage ? { coldStage: true } : {}),
+    },
     traceState: (body.traceState ?? 'ALL') as TraceQueryState,
     queryOrder: (body.queryOrder ?? 'BY_START_TIME') as TraceQueryOrder,
     paging: {
@@ -256,6 +275,7 @@ async function fetchNativeList(
   opts: GraphqlOptions,
   body: TraceListBody,
   layerKey: string,
+  coldStage: boolean,
 ): Promise<NativeTraceListResponse> {
   const api = await detectTraceQueryApi(opts);
   // Explicit start+end takes precedence over windowMinutes; falling
@@ -278,7 +298,7 @@ async function fetchNativeList(
       error: err instanceof Error ? err.message : String(err),
     };
   }
-  const condition = buildTraceCondition(body, serviceId, window);
+  const condition = buildTraceCondition(body, serviceId, window, coldStage);
   try {
     if (api === 'queryTraces') {
       const env = await graphqlPost<{
@@ -380,7 +400,7 @@ export function registerTraceRoutes(app: FastifyInstance, deps: TraceRouteDeps):
       // Fan out in parallel; partial failures don't drop the whole
       // response — the UI's empty / error states cover each slot.
       const [native, zipkin] = await Promise.all([
-        wantNative ? fetchNativeList(opts, body, layerKey) : Promise.resolve(undefined),
+        wantNative ? fetchNativeList(opts, body, layerKey, !!req.coldStage) : Promise.resolve(undefined),
         wantZipkin ? fetchZipkinList(opts, body) : Promise.resolve(undefined),
       ]);
 
@@ -399,17 +419,44 @@ export function registerTraceRoutes(app: FastifyInstance, deps: TraceRouteDeps):
     { preHandler: auth },
     async (req: FastifyRequest, reply: FastifyReply) => {
       const params = req.params as { traceId: string };
-      const q = req.query as { source?: 'native' | 'zipkin' };
+      const q = req.query as {
+        source?: 'native' | 'zipkin';
+        /** Approximate window the trace lives in (epoch ms + OAP step).
+         *  When provided, the BFF spells the window in OAP-server TZ
+         *  via `windowFromRange` and forwards it as `queryTrace.duration`
+         *  so BanyanDB looks beyond its default 1-day search. Paired
+         *  with the cold-stage header, this lets a trace ID from a log
+         *  row resolve even when the trace data lives in the cold tier. */
+        startMs?: string;
+        endMs?: string;
+        step?: 'MINUTE' | 'HOUR' | 'DAY';
+      };
       const source: 'native' | 'zipkin' = q.source === 'zipkin' ? 'zipkin' : 'native';
       const opts = buildOapOpts(deps.config.current, deps.fetch);
 
       if (source === 'native') {
         const api = await detectTraceQueryApi(opts);
         try {
+          // When the caller supplies an approximate window, forward it
+          // as the optional `duration` so BanyanDB looks beyond its
+          // default 1-day window. `withColdStage` adds `coldStage: true`
+          // when the operator has the Cold pill on, letting trace IDs
+          // whose data lives in the cold tier resolve from log rows.
+          const startMs = Number(q.startMs);
+          const endMs = Number(q.endMs);
+          let duration: { start: string; end: string; step: string; coldStage?: true } | undefined;
+          if (
+            (q.step === 'MINUTE' || q.step === 'HOUR' || q.step === 'DAY') &&
+            Number.isFinite(startMs) &&
+            Number.isFinite(endMs)
+          ) {
+            const w = windowFromRange(q.step, startMs, endMs);
+            if (w) duration = withColdStage(req, { start: w.start, end: w.end, step: w.step });
+          }
           const env = await graphqlPost<{ trace: { spans: NativeSpan[] } }>(
             opts,
             QUERY_TRACE_DETAIL,
-            { traceId: params.traceId },
+            { traceId: params.traceId, duration },
           );
           const detail: NativeTraceDetailResponse = {
             source: 'native',
diff --git a/apps/bff/src/http/query/ttl.ts b/apps/bff/src/http/query/ttl.ts
index cd1f150..c505238 100644
--- a/apps/bff/src/http/query/ttl.ts
+++ b/apps/bff/src/http/query/ttl.ts
@@ -20,6 +20,15 @@
  * `getMetricsTTL` on the query port. Read-only data-retention view.
  * Never rejects on an unreachable OAP: returns `{ reachable: false }`
  * so the page degrades instead of 502-ing.
+ *
+ * On top of the raw OAP fields, we derive:
+ *   - `backend` — `banyandb` / `other` / `unknown` from the TTL shape
+ *     (see {@link classifyBackend}); cached upstream so this poll
+ *     reuses it.
+ *   - `stages.hot` + `stages.cold` — a per-stage view that lets the UI
+ *     render a single component twice instead of branching on the raw
+ *     field names. `stages.cold` is `null` when no `cold*` field is
+ *     configured for any class.
  */
 
 import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
@@ -28,11 +37,13 @@ import type {
   MetricsTTL,
   OapTtlResponse,
   RecordsTTL,
+  TtlStageBreakdown,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+import { classifyBackend } from '../../logic/oap/backend.js';
 
 const TTL_QUERY = /* GraphQL */ `
   query HorizonOapTtl {
@@ -41,7 +52,7 @@ const TTL_QUERY = /* GraphQL */ `
       coldNormal coldTrace coldZipkinTrace coldLog coldBrowserErrorLog
     }
     metrics: getMetricsTTL {
-      minute hour day
+      metadata minute hour day
       coldMinute coldHour coldDay
     }
   }
@@ -52,6 +63,53 @@ interface TtlRaw {
   metrics?: MetricsTTL | null;
 }
 
+/** Compose `stages.hot` + `stages.cold` from the raw OAP fields. The
+ *  cold pane is `null` when every `cold*` field is `-1` (BanyanDB
+ *  without a cold lifecycle stage, or any non-BanyanDB backend). */
+export function deriveStages(raw: TtlRaw): { hot: TtlStageBreakdown; cold: TtlStageBreakdown | null } | null {
+  const r = raw.records;
+  const m = raw.metrics;
+  if (!r || !m) return null;
+  const hot: TtlStageBreakdown = {
+    records: {
+      normal: r.normal,
+      trace: r.trace,
+      zipkinTrace: r.zipkinTrace,
+      log: r.log,
+      browserErrorLog: r.browserErrorLog,
+    },
+    metrics: {
+      metadata: m.metadata,
+      minute: m.minute,
+      hour: m.hour,
+      day: m.day,
+    },
+  };
+  const coldVals = [
+    r.coldNormal, r.coldTrace, r.coldZipkinTrace, r.coldLog, r.coldBrowserErrorLog,
+    m.coldMinute, m.coldHour, m.coldDay,
+  ];
+  const hasCold = coldVals.some((v) => typeof v === 'number' && v >= 0);
+  if (!hasCold) return { hot, cold: null };
+  const cold: TtlStageBreakdown = {
+    records: {
+      normal: r.coldNormal,
+      trace: r.coldTrace,
+      zipkinTrace: r.coldZipkinTrace,
+      log: r.coldLog,
+      browserErrorLog: r.coldBrowserErrorLog,
+    },
+    metrics: {
+      // OAP does not surface a cold metadata TTL — drop the field
+      // (the UI hides cards with `undefined` values).
+      minute: m.coldMinute,
+      hour: m.coldHour,
+      day: m.coldDay,
+    },
+  };
+  return { hot, cold };
+}
+
 export interface TtlRouteDeps {
   config: ConfigSource;
   sessions: SessionStore;
@@ -64,15 +122,19 @@ export function registerTtlRoute(app: FastifyInstance, deps: TtlRouteDeps): void
     const cfg = deps.config.current;
     try {
       const raw = await graphqlPost<TtlRaw>(buildOapOpts(cfg, deps.fetch), TTL_QUERY);
+      const stages = deriveStages(raw);
       const body: OapTtlResponse = {
         reachable: true,
         records: raw.records ?? undefined,
         metrics: raw.metrics ?? undefined,
+        backend: classifyBackend(raw),
+        stages: stages ?? undefined,
       };
       return reply.send(body);
     } catch (err) {
       const body: OapTtlResponse = {
         reachable: false,
+        backend: 'unknown',
         error: err instanceof Error ? err.message : String(err),
       };
       return reply.status(200).send(body);
diff --git a/apps/bff/src/logic/oap/backend.ts b/apps/bff/src/logic/oap/backend.ts
new file mode 100644
index 0000000..1c94131
--- /dev/null
+++ b/apps/bff/src/logic/oap/backend.ts
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+
+/**
+ * Detect which storage OAP is using, by inspecting `getRecordsTTL` /
+ * `getMetricsTTL`. OAP doesn't expose the storage choice as a first-class
+ * GraphQL field, but the TTL response shape is reliably different:
+ *
+ *   - Non-BanyanDB (Default core impl): every record/metric field returns
+ *     the same `coreXxxTTL`, and every `cold*` field is `-1`.
+ *   - BanyanDB: per-class TTLs (trace / log / minute / hour / day) come
+ *     from each model's own config and are usually non-uniform; cold*
+ *     fields hold real values when the cold lifecycle stage is on; and
+ *     `MetricsTTL.metadata` is independently configured.
+ *
+ * Heuristic used:
+ *   - Any `cold*` field >= 0          ⇒ banyandb (only it ever sets cold)
+ *   - Any per-class TTL differs       ⇒ banyandb (default impl is uniform)
+ *   - Metadata != minute              ⇒ banyandb (default impl unifies)
+ *   - Otherwise                       ⇒ other
+ *
+ * The corner case — a BanyanDB instance with hot-only AND uniform per-class
+ * TTLs — looks identical to the default impl on the wire and reports as
+ * `other`. That's acceptable: the only behavior we gate on the result is
+ * the visibility of the cold-stage UI affordance, and in that corner case
+ * the BanyanDB instance has no cold stage to query anyway.
+ *
+ * Cached per-queryUrl for 5 min; failures cached for 60s so a transient
+ * OAP outage recovers quickly without re-probing on every poll.
+ */
+
+import type { FetchLike, OapBackend, RecordsTTL, MetricsTTL } from '@skywalking-horizon-ui/api-client';
+import type { HorizonConfig } from '../../config/schema.js';
+import { buildOapOpts, graphqlPost } from '../../client/graphql.js';
+
+const TTL_PROBE_QUERY = /* GraphQL */ `
+  query HorizonOapBackendProbe {
+    records: getRecordsTTL {
+      normal trace zipkinTrace log browserErrorLog
+      coldNormal coldTrace coldZipkinTrace coldLog coldBrowserErrorLog
+    }
+    metrics: getMetricsTTL {
+      metadata minute hour day
+      coldMinute coldHour coldDay
+    }
+  }
+`;
+
+interface TtlProbeRaw {
+  records?: RecordsTTL | null;
+  metrics?: MetricsTTL | null;
+}
+
+interface Entry {
+  backend: OapBackend;
+  fetchedAt: number;
+}
+const cache = new Map<string, Entry>();
+const TTL_OK_MS = 5 * 60_000;
+const TTL_FAIL_MS = 60_000;
+
+/** Reset the per-queryUrl cache. Test-only. */
+export function _resetBackendCache(): void {
+  cache.clear();
+}
+
+export function classifyBackend(raw: TtlProbeRaw): OapBackend {
+  const r = raw.records;
+  const m = raw.metrics;
+  if (!r || !m) return 'unknown';
+  const coldVals = [
+    r.coldNormal, r.coldTrace, r.coldZipkinTrace, r.coldLog, r.coldBrowserErrorLog,
+    m.coldMinute, m.coldHour, m.coldDay,
+  ];
+  if (coldVals.some((v) => typeof v === 'number' && v >= 0)) return 'banyandb';
+  const recVals = [r.normal, r.trace, r.zipkinTrace, r.log, r.browserErrorLog];
+  if (new Set(recVals).size > 1) return 'banyandb';
+  const metVals = [m.minute, m.hour, m.day];
+  if (new Set(metVals).size > 1) return 'banyandb';
+  if (typeof m.metadata === 'number' && m.metadata !== m.minute) return 'banyandb';
+  return 'other';
+}
+
+export async function probeTtl(
+  config: HorizonConfig,
+  fetchImpl?: FetchLike,
+): Promise<TtlProbeRaw> {
+  return graphqlPost<TtlProbeRaw>(buildOapOpts(config, fetchImpl), TTL_PROBE_QUERY);
+}
+
+export async function getOapBackend(
+  config: HorizonConfig,
+  fetchImpl?: FetchLike,
+): Promise<OapBackend> {
+  const key = config.oap.queryUrl;
+  const now = Date.now();
+  const hit = cache.get(key);
+  if (hit && now - hit.fetchedAt < TTL_OK_MS) return hit.backend;
+  try {
+    const raw = await probeTtl(config, fetchImpl);
+    const backend = classifyBackend(raw);
+    cache.set(key, { backend, fetchedAt: now });
+    return backend;
+  } catch {
+    // Conservative: report unknown rather than guessing. Short cache
+    // so the next poll re-probes and recovers when OAP comes back.
+    cache.set(key, { backend: 'unknown', fetchedAt: now - TTL_OK_MS + TTL_FAIL_MS });
+    return 'unknown';
+  }
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 82b7c3a..9198917 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -73,6 +73,7 @@ import { registerOverviewTemplatesAdminRoutes } from './http/admin/overview-temp
 import { registerAuthStatusRoutes } from './http/admin/auth-status.js';
 import { registerAdminUsersRoute } from './http/admin/users.js';
 import { registerAuthHealthRoute } from './http/auth-health.js';
+import { registerColdStageHook } from './util/duration.js';
 // Logic / stores
 import { AlarmsStore } from './logic/alarms/store.js';
 import { SetupStore } from './logic/setup/store.js';
@@ -137,6 +138,12 @@ await app.register(cookie);
 // Text/plain body parser — the rule editor sends raw YAML to /api/rule.
 app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));
 
+// Map the UI's cold-stage header (X-Horizon-Cold-Stage) onto
+// `req.coldStage` so every downstream Duration helper sees one boolean
+// instead of re-parsing the header. BanyanDB-only at the OAP layer;
+// other backends silently ignore the resulting Duration.coldStage:true.
+registerColdStageHook(app);
+
 // Auto-apply RBAC pre-handlers to every route as it's registered. Must
 // be added BEFORE the route registrations below — onRoute fires for
 // each subsequent app.get/post/...
diff --git a/apps/bff/src/util/duration.ts b/apps/bff/src/util/duration.ts
new file mode 100644
index 0000000..fb73e1f
--- /dev/null
+++ b/apps/bff/src/util/duration.ts
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+
+/**
+ * Per-request "query cold stage" plumbing.
+ *
+ * The UI carries the operator's choice in a request header
+ * (`X-Horizon-Cold-Stage: 1`) so it threads through every BFF call
+ * without each route having to add a query-param. A Fastify `onRequest`
+ * hook reads the header once and stashes the boolean on `req.coldStage`;
+ * route handlers building OAP `Duration` variables call
+ * {@link withColdStage} to splice the flag in. For routes that
+ * interpolate Duration inline (e.g. `dashboard.ts`'s aliased fragment
+ * builder), the route reads `req.coldStage` directly and emits the
+ * `, coldStage: true` literal in its own template.
+ *
+ * Semantics worth remembering: OAP's `Duration.coldStage: true`
+ * REPLACES the hot+warm read with a cold-only read — it doesn't union
+ * the two stages. The BFF always honors the header verbatim and does
+ * NOT auto-route by time range; the UI carries the operator-discipline
+ * burden of only enabling the toggle when the queried window falls
+ * inside the cold-stage time window (see `Operate → Time To Live`).
+ * BanyanDB-only at the OAP layer — other backends silently ignore the
+ * resulting `Duration.coldStage: true`.
+ */
+
+import type { FastifyInstance, FastifyRequest } from 'fastify';
+
+export const COLD_STAGE_HEADER = 'x-horizon-cold-stage';
+
+declare module 'fastify' {
+  interface FastifyRequest {
+    /** True when the UI asked for cold-stage data to be included.
+     *  False / undefined when omitted. Set once per request by the
+     *  cold-stage `onRequest` hook. */
+    coldStage?: boolean;
+  }
+}
+
+/** Register the `onRequest` hook that maps the header into `req.coldStage`.
+ *  Call once at server boot. The header is treated truthy when it's
+ *  `1` / `true` / `yes` (case-insensitive); everything else is false. */
+export function registerColdStageHook(app: FastifyInstance): void {
+  app.addHook('onRequest', (req, _reply, done) => {
+    const v = req.headers[COLD_STAGE_HEADER];
+    const raw = Array.isArray(v) ? v[0] : v;
+    if (typeof raw === 'string') {
+      const norm = raw.trim().toLowerCase();
+      req.coldStage = norm === '1' || norm === 'true' || norm === 'yes';
+    } else {
+      req.coldStage = false;
+    }
+    done();
+  });
+}
+
+/** Splice `coldStage: true` into a Duration variable object when the
+ *  request asked for it. Returns the input unchanged otherwise so the
+ *  wire stays byte-identical for off-state requests (lets the existing
+ *  query-protocol golden tests keep passing). */
+export function withColdStage<T extends { start: string; end: string; step: string }>(
+  req: Pick<FastifyRequest, 'coldStage'>,
+  d: T,
+): T | (T & { coldStage: true }) {
+  return req.coldStage ? { ...d, coldStage: true } : d;
+}
diff --git a/apps/bff/src/util/window.ts b/apps/bff/src/util/window.ts
new file mode 100644
index 0000000..3c85fb6
--- /dev/null
+++ b/apps/bff/src/util/window.ts
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ */
+
+/**
+ * Shared time-window helpers for any BFF route that forwards a
+ * SPA-supplied time range to OAP. Originally inlined inside
+ * `dashboard.ts`; broken out here so `landing.ts` / `topology.ts` /
+ * future query routes don't drift their own copies.
+ *
+ * OAP's `verifyDateTimeString` rejects when the string precision
+ * doesn't match the Duration.step — `fmtForStep` is the only place
+ * that knows the mapping. All times are formatted in UTC; callers
+ * are responsible for converting the operator's browser-local time
+ * to UTC before calling these (the UI does this; see CLAUDE.md
+ * "Time, step, and timezone").
+ */
+
+export type TimeStep = 'MINUTE' | 'HOUR' | 'DAY';
+
+export interface Window {
+  start: string;
+  end: string;
+  step: TimeStep;
+}
+
+function pad(n: number): string {
+  return String(n).padStart(2, '0');
+}
+export function fmtMinute(d: Date): string {
+  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}`;
+}
+export function fmtHour(d: Date): string {
+  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}`;
+}
+export function fmtDay(d: Date): string {
+  return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
+}
+
+/** Format a Date for OAP per the step. */
+export function fmtForStep(step: TimeStep, d: Date): string {
+  if (step === 'DAY') return fmtDay(d);
+  if (step === 'HOUR') return fmtHour(d);
+  return fmtMinute(d);
+}
+
+/** Last-hour MINUTE window. Used as the fallback when a route runs
+ *  without a SPA time range (e.g. background pollers, legacy callers). */
+export function defaultMinuteWindow(minutesBack = 60): Window {
+  const end = new Date();
+  end.setUTCSeconds(0, 0);
+  const start = new Date(end.getTime() - minutesBack * 60_000);
+  return { start: fmtMinute(start), end: fmtMinute(end), step: 'MINUTE' };
+}
+
+/** Build an OAP {@link Window} from the SPA-supplied range. All three
+ *  inputs must be present and `endMs > startMs`; returns null otherwise
+ *  so the caller can fall back to {@link defaultMinuteWindow}. */
+export function windowFromRange(
+  step: TimeStep,
+  startMs: number,
+  endMs: number,
+): Window | null {
+  if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return null;
+  return {
+    start: fmtForStep(step, new Date(startMs)),
+    end: fmtForStep(step, new Date(endMs)),
+    step,
+  };
+}
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index b447c15..978d900 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -47,6 +47,7 @@ import type {
 } from '@skywalking-horizon-ui/api-client';
 
 import { pushEvent } from '@/controls/eventLog';
+import { COLD_STAGE_HEADER, readColdStageHeader } from '@/controls/coldStage';
 import { SessionApi } from './scopes/session';
 
 /** Deploy-base prefix for every API call. Pulled from Vite's
@@ -596,11 +597,15 @@ export class BffClient {
     body?: unknown,
     headers?: Record<string, string>,
   ): Promise<T> {
+    // Cold-stage flag is per-request not per-call-site, so the
+    // interceptor reads the persisted toggle here rather than every
+    // sub-client having to forward it. OAP ignores it for non-BanyanDB.
     const init: RequestInit = {
       method,
       credentials: 'include',
       headers: {
         ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
+        ...(readColdStageHeader() ? { [COLD_STAGE_HEADER]: '1' } : {}),
         ...headers,
       },
     };
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index 71625b1..54734c5 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -181,10 +181,20 @@ export class LayerApi {
     );
   }
 
-  topology(layerKey: string, service?: string, depth = 1): Promise<TopologyResponse> {
+  topology(
+    layerKey: string,
+    service?: string,
+    depth = 1,
+    range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
+  ): Promise<TopologyResponse> {
     const qs = new URLSearchParams();
     if (service) qs.set('service', service);
     qs.set('depth', String(depth));
+    if (range) {
+      qs.set('step', range.step);
+      qs.set('startMs', String(range.startMs));
+      qs.set('endMs', String(range.endMs));
+    }
     return this.bff.request(
       'GET',
       `/api/layer/${encodeURIComponent(layerKey)}/topology?${qs.toString()}`,
diff --git a/apps/ui/src/api/scopes/trace.ts b/apps/ui/src/api/scopes/trace.ts
index 60aafa7..1dd436c 100644
--- a/apps/ui/src/api/scopes/trace.ts
+++ b/apps/ui/src/api/scopes/trace.ts
@@ -59,8 +59,20 @@ export class TraceApi {
   detail(
     traceId: string,
     source: 'native' | 'zipkin' = 'native',
+    /** Approximate window the trace lives in (epoch ms + OAP step).
+     *  Required when the trace is older than ~1 day on BanyanDB —
+     *  `queryTrace` defaults to a 1-day search and an older trace ID
+     *  returns null without it. Callers without a hint (e.g. directly
+     *  pasting a trace ID into the URL) can omit and rely on the
+     *  1-day fallback. The BFF converts ms → OAP-server-TZ format. */
+    range?: { startMs: number; endMs: number; step: 'MINUTE' | 'HOUR' | 'DAY' },
   ): Promise<TraceDetailResponse> {
     const qs = new URLSearchParams({ source });
+    if (range) {
+      qs.set('startMs', String(range.startMs));
+      qs.set('endMs', String(range.endMs));
+      qs.set('step', range.step);
+    }
     return this.bff.request<TraceDetailResponse>(
       'GET',
       `/api/trace/${encodeURIComponent(traceId)}?${qs.toString()}`,
diff --git a/apps/ui/src/components/icons/Icon.vue b/apps/ui/src/components/icons/Icon.vue
index 71b6f1f..b6dcb2e 100644
--- a/apps/ui/src/components/icons/Icon.vue
+++ b/apps/ui/src/components/icons/Icon.vue
@@ -51,7 +51,8 @@ export type IconName =
   | 'web'
   | 'fn'
   | 'cache'
-  | 'topic';
+  | 'topic'
+  | 'snowflake';
 </script>
 
 <template>
@@ -214,5 +215,9 @@ export type IconName =
       <circle cx="12" cy="12" r="8" />
       <path d="M4 12h16M12 4v16" opacity="0.6" />
     </template>
+    <template v-else-if="name === 'snowflake'">
+      <path d="M12 3v18M3 12h18M5 5l14 14M19 5L5 19" />
+      <path d="M9 6l3-3 3 3M15 18l-3 3-3-3M6 9L3 12l3 3M18 9l3 3-3 3" />
+    </template>
   </svg>
 </template>
diff --git a/apps/ui/src/components/primitives/CatalogNav.vue b/apps/ui/src/components/primitives/CatalogNav.vue
index f5bf14e..8e12f9f 100644
--- a/apps/ui/src/components/primitives/CatalogNav.vue
+++ b/apps/ui/src/components/primitives/CatalogNav.vue
@@ -46,7 +46,7 @@ const sections: NavSection[] = [
     ],
   },
   {
-    kicker: 'DSL Management',
+    kicker: 'DSL management',
     links: [
       {
         label: 'MAL · OTEL',
diff --git a/apps/ui/src/controls/coldStage.ts b/apps/ui/src/controls/coldStage.ts
new file mode 100644
index 0000000..90b420d
--- /dev/null
+++ b/apps/ui/src/controls/coldStage.ts
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+/**
+ * "Query cold stage" — a process-wide toggle that switches every
+ * OAP read from hot+warm to BanyanDB's cold stage.
+ *
+ * IMPORTANT — read this before editing: OAP's `Duration.coldStage: true`
+ * REPLACES the hot+warm read, it does not union with it (see the
+ * comment on `Duration.coldStage` in OAP's common.graphqls, and the
+ * BanyanDBTTLStatusQuery source). For the demo OAP with hot+warm=8d /
+ * cold=30d, "ON" returns data only when the queried window falls
+ * within roughly 8–38 days ago; turning it on while looking at a
+ * recent dashboard makes every widget go empty. The UI surfaces this
+ * trap loudly in the topbar tooltip + the TTL page; the toggle itself
+ * is intentionally manual (operator discipline) rather than auto-
+ * routed by time range — the latter would double wire traffic on
+ * windows that span the boundary.
+ *
+ * Wire path: the flag travels with every BFF request via the
+ * `X-Horizon-Cold-Stage` header (see {@link COLD_STAGE_HEADER}). The
+ * BFF maps the header onto `req.coldStage` and splices
+ * `coldStage: true` into every OAP `Duration` it constructs. OAP
+ * silently ignores the field for non-BanyanDB storage, so the chrome
+ * is safe to send always — the topbar hides the affordance when
+ * `backend !== 'banyandb'` so other-backend operators aren't offered
+ * a no-op switch.
+ *
+ * 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.
+ */
+
+import { defineStore } from 'pinia';
+import { ref, watch } from 'vue';
+import type { QueryClient } from '@tanstack/vue-query';
+
+export const COLD_STAGE_HEADER = 'X-Horizon-Cold-Stage';
+
+const STORAGE_KEY = 'horizon:coldStage:v1';
+
+function detectInitial(): boolean {
+  if (typeof localStorage !== 'undefined') {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    if (raw === '1') return true;
+  }
+  return false;
+}
+
+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) => {
+    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 {
+    if (enabled.value === on) return;
+    enabled.value = on;
+    client?.invalidateQueries();
+  }
+
+  return { enabled, toggle, set };
+});
+
+/** Synchronous snapshot for the API client's fetch interceptor —
+ *  called per request and must not depend on a Vue component context.
+ *  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 (typeof localStorage === 'undefined') return false;
+  try {
+    return localStorage.getItem(STORAGE_KEY) === '1';
+  } catch {
+    return false;
+  }
+}
diff --git a/apps/ui/src/features/admin/roles/RolesView.vue b/apps/ui/src/features/admin/roles/RolesView.vue
index 70d4f7e..324de5e 100644
--- a/apps/ui/src/features/admin/roles/RolesView.vue
+++ b/apps/ui/src/features/admin/roles/RolesView.vue
@@ -72,12 +72,12 @@ const MENU_GATES: ReadonlyArray<{ label: string; verb: string | null }> = [
   { label: 'Overviews', verb: 'overview:read' },
   { label: 'Cluster status', verb: 'cluster:read' },
   { label: 'Platform monitoring (layers)', verb: 'cluster:read' },
-  { label: 'Metrics Inspect', verb: 'inspect:read' },
+  { label: 'Metrics inspect', verb: 'inspect:read' },
   { label: 'Data retention', verb: 'ttl:read' },
   { label: 'OAP configuration', verb: 'config:read' },
   { label: 'Alerting rules', verb: 'alarm-rule:read' },
   { label: 'Live debugger · Capture history', verb: 'live-debug:read' },
-  { label: 'DSL Management', verb: 'rule:read' },
+  { label: 'DSL management', verb: 'rule:read' },
   { label: 'Overview templates', verb: 'overview:write' },
   { label: 'Layer dashboards', verb: 'dashboard:read' },
   { label: 'Alert page', verb: 'alarm-setup:read' },
diff --git a/apps/ui/src/features/alarms/AlarmsView.vue b/apps/ui/src/features/alarms/AlarmsView.vue
index 8982418..27e45a3 100644
--- a/apps/ui/src/features/alarms/AlarmsView.vue
+++ b/apps/ui/src/features/alarms/AlarmsView.vue
@@ -534,9 +534,18 @@ function formatRelative(ts: number): string {
   return `${d}d ${h % 24}h ago`;
 }
 
+/** Compact, locale-independent stamp for the custom window. Same
+ *  `MM-DD HH:mm` shape as the topbar time chip — keeps the dense UI
+ *  consistent and avoids `toLocaleString` picking up zh-CN / locale-
+ *  specific formats (the bug fixed earlier on log timestamps). */
+function fmtStamp(ms: number): string {
+  const d = new Date(ms);
+  const p = (n: number) => String(n).padStart(2, '0');
+  return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
 function formatWindowLabel(): string {
   if (windowMode.value === 'custom') {
-    return `${new Date(customStart.value).toLocaleString()} → ${new Date(customEnd.value).toLocaleString()}`;
+    return `${fmtStamp(customStart.value)} → ${fmtStamp(customEnd.value)}`;
   }
   return `last ${windowMode.value}`;
 }
diff --git a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
index d7a1871..8b678e9 100644
--- a/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
+++ b/apps/ui/src/features/operate/cluster/ClusterStatusView.vue
@@ -123,7 +123,7 @@ function refreshAll(): void {
         <p class="lede">
           Two-port view of the OAP backend horizon is connected to.
           Query / GraphQL (<code>:12800</code>) drives every observability page;
-          the admin host (<code>:17128</code>) gates DSL Management, Live Debugger, Inspect, and Dump;
+          the admin host (<code>:17128</code>) 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.
         </p>
diff --git a/apps/ui/src/features/operate/dsl/DslCatalogView.vue b/apps/ui/src/features/operate/dsl/DslCatalogView.vue
index 99938b3..4d7905d 100644
--- a/apps/ui/src/features/operate/dsl/DslCatalogView.vue
+++ b/apps/ui/src/features/operate/dsl/DslCatalogView.vue
@@ -251,7 +251,7 @@ function submitNewRule(): void {
 
 <template>
   <div class="catalog">
-    <AdminFeatureWarning module="receiver-runtime-rule" feature-label="DSL Management" />
+    <AdminFeatureWarning module="receiver-runtime-rule" feature-label="DSL management" />
     <header class="catalog__header">
       <h1 class="catalog__h1">
         Catalog · <span class="catalog__catalog">{{ catalog ?? 'unknown' }}</span>
diff --git a/apps/ui/src/features/operate/ttl/TtlView.vue b/apps/ui/src/features/operate/ttl/TtlView.vue
index d5a78df..6335444 100644
--- a/apps/ui/src/features/operate/ttl/TtlView.vue
+++ b/apps/ui/src/features/operate/ttl/TtlView.vue
@@ -14,56 +14,203 @@
   See the License for the specific language governing permissions and
   limitations under the License.
 -->
+<!--
+  Read-only data-retention view.
+
+  Render branches on the storage backend the BFF probes (BanyanDB vs
+  other). For BanyanDB the page renders TWO stages — HOT+WARM (queried
+  by default) and COLD (opt-in via the topbar cold-stage chip). For
+  any other backend it renders a single uniform stage; the cold pane
+  is hidden because OAP doesn't surface cold data there.
+
+  OAP does NOT expose the hot-vs-warm split on the wire — the value
+  shown for "hot + warm" is the sum the OAP-side resolver already
+  computed. Don't try to derive a warm-only number here; if the
+  operator wants the breakdown they read the BanyanDB config.
+
+  Cold semantics worth knowing (and surfacing to the operator):
+  OAP's `Duration.coldStage: true` REPLACES the hot+warm read with a
+  cold-only read — it doesn't union the two. Flipping the topbar Cold
+  pill on while looking at a recent window makes every widget go
+  empty, because cold doesn't hold recent data. The tip rendered
+  below the lede when the toggle is on quotes the approximate cold
+  window (derived from the trace TTL pair — most operator-relevant
+  data class). Per-class boundaries differ slightly; this is a hint,
+  not a hard guarantee (segment migration shifts boundaries; recent
+  edits can briefly leave records in both stages).
+-->
 <script setup lang="ts">
 import { computed } from 'vue';
+import type { TtlStageBreakdown } from '@skywalking-horizon-ui/api-client';
 import { useTtl } from './useTtl';
+import { useColdStageStore } from '@/controls/coldStage';
 
-// Read-only data-retention view. OAP reports TTL in whole DAYS, split by
-// hot/warm (the plain field) and cold (`cold*`). A cold value of -1 means
-// the backend has no cold stage configured, rendered as "no cold stage".
+const { reachable, data, isLoading, refetch } = useTtl();
+const cold = useColdStageStore();
 
-const { reachable, records, metrics, data, isLoading, refetch } = useTtl();
+const backend = computed(() => data.value?.backend ?? 'unknown');
+const isBanyanDB = computed(() => backend.value === 'banyandb');
+const stages = computed(() => data.value?.stages ?? null);
+const hot = computed<TtlStageBreakdown | null>(() => stages.value?.hot ?? null);
+const coldStage = computed<TtlStageBreakdown | null>(() => stages.value?.cold ?? null);
 
 interface Row {
   label: string;
-  hot: number | undefined;
-  cold?: number | undefined;
-  /** metadata has no cold counterpart. */
-  hasCold: boolean;
+  days: number | undefined;
 }
 
-const recordRows = computed<Row[]>(() => {
-  const r = records.value;
-  if (!r) return [];
+function recordsRows(s: TtlStageBreakdown): Row[] {
   return [
-    { label: 'Normal', hot: r.normal, cold: r.coldNormal, hasCold: true },
-    { label: 'Trace', hot: r.trace, cold: r.coldTrace, hasCold: true },
-    { label: 'Zipkin trace', hot: r.zipkinTrace, cold: r.coldZipkinTrace, hasCold: true },
-    { label: 'Log', hot: r.log, cold: r.coldLog, hasCold: true },
-    { label: 'Browser error log', hot: r.browserErrorLog, cold: r.coldBrowserErrorLog, hasCold: true },
+    { label: 'Normal',           days: s.records.normal },
+    { label: 'Trace',            days: s.records.trace },
+    { label: 'Zipkin trace',     days: s.records.zipkinTrace },
+    { label: 'Log',              days: s.records.log },
+    { label: 'Browser error log', days: s.records.browserErrorLog },
   ];
-});
-
-const metricRows = computed<Row[]>(() => {
-  const m = metrics.value;
-  if (!m) return [];
+}
+function metricsRows(s: TtlStageBreakdown): Row[] {
   return [
-    // `metadata` isn't exposed by every OAP deployment; omit when absent.
-    ...(m.metadata !== undefined ? [{ label: 'Metadata', hot: m.metadata, hasCold: false }] : []),
-    { label: 'Minute', hot: m.minute, cold: m.coldMinute, hasCold: true },
-    { label: 'Hour', hot: m.hour, cold: m.coldHour, hasCold: true },
-    { label: 'Day', hot: m.day, cold: m.coldDay, hasCold: true },
+    ...(s.metrics.metadata !== undefined ? [{ label: 'Metadata', days: s.metrics.metadata }] : []),
+    { label: 'Minute', days: s.metrics.minute },
+    { label: 'Hour',   days: s.metrics.hour },
+    { label: 'Day',    days: s.metrics.day },
   ];
+}
+function fmtDays(n: number | undefined): string {
+  if (n === undefined) return '—';
+  if (n < 0) return '—';
+  return `${n} d`;
+}
+
+/** Cold-window tip when the toggle is on AND a cold stage is
+ *  configured. Quotes the *approximate* cold window so the operator
+ *  picks a time range that intersects it — otherwise the cold-only
+ *  read returns nothing. Uses the trace TTL pair as the reference;
+ *  other classes may differ slightly. */
+const coldTip = computed<string | null>(() => {
+  if (!cold.enabled) return null;
+  if (!isBanyanDB.value) return null;
+  if (!hot.value || !coldStage.value) return null;
+  const hotTrace = hot.value.records.trace;
+  const coldTrace = coldStage.value.records.trace;
+  if (!Number.isFinite(hotTrace) || coldTrace < 0) return null;
+  // Cold window for traces: data aged past the hot+warm tail
+  // (hotTrace days back) and still within hot+warm+cold
+  // (hotTrace + coldTrace days back).
+  const start = hotTrace;
+  const end = hotTrace + coldTrace;
+  return (
+    `Cold-only read is ACTIVE. OAP queries the cold stage INSTEAD of hot+warm — recent windows return empty. ` +
+    `Pick a time range roughly ${start}–${end} days ago to see cold-stage traces; other data classes may have different boundaries — check the cards below.`
+  );
 });
 
-function days(n: number | undefined): string {
-  return n === undefined ? '—' : `${n} d`;
+const backendPillLabel = computed(() => {
+  if (backend.value === 'banyandb') return 'BanyanDB';
+  if (backend.value === 'other') return 'standard';
+  return 'detecting…';
+});
+
+/** One bar per data class, grouped under Records / Metrics so the
+ *  layout mirrors the cards below. Row widths are proportional to
+ *  total retention across ALL rows — longest-lived class (often
+ *  `metadata` or the cold-enabled metrics tiers) visibly stretches
+ *  further right, so an operator can see at a glance which class
+ *  outlives which. Classes with hotDays <= 0 (unknown / not
+ *  exposed by the wire) drop out.
+ *
+ *  IMPORTANT — what's exposed: OAP's `getRecordsTTL` /
+ *  `getMetricsTTL` collapse hot + warm into one number per class
+ *  (see BanyanDBTTLStatusQuery in OAP). The "Hot + Warm" segment
+ *  width here is that combined value, NOT the hot-only TTL. Don't
+ *  try to split — the wire doesn't tell us. The footer note
+ *  surfaces this so the operator isn't misled. */
+interface LifecycleRow {
+  label: string;
+  hotDays: number;
+  /** 0 when this class has no cold stage (any non-BanyanDB backend,
+   *  or BanyanDB without a cold lifecycle stage configured). */
+  coldDays: number;
+  totalDays: number;
 }
-function coldLabel(r: Row): string {
-  if (!r.hasCold) return 'no cold stage';
-  if (r.cold === undefined) return '—';
-  return r.cold < 0 ? 'no cold stage' : `cold: ${r.cold} d`;
+
+const lifecycleRecords = computed<LifecycleRow[]>(() => {
+  const h = hot.value;
+  if (!h) return [];
+  const c = coldStage.value;
+  const coldOr0 = (v: number | undefined): number => (v != null && v >= 0 ? v : 0);
+  const rows: LifecycleRow[] = [
+    { label: 'Normal',          hotDays: h.records.normal,          coldDays: coldOr0(c?.records.normal),          totalDays: 0 },
+    { label: 'Trace',           hotDays: h.records.trace,           coldDays: coldOr0(c?.records.trace),           totalDays: 0 },
+    { label: 'Zipkin trace',    hotDays: h.records.zipkinTrace,     coldDays: coldOr0(c?.records.zipkinTrace),     totalDays: 0 },
+    { label: 'Log',             hotDays: h.records.log,             coldDays: coldOr0(c?.records.log),             totalDays: 0 },
+    { label: 'Browser err log', hotDays: h.records.browserErrorLog, coldDays: coldOr0(c?.records.browserErrorLog), totalDays: 0 },
+  ];
+  return rows
+    .filter((r) => Number.isFinite(r.hotDays) && r.hotDays > 0)
+    .map((r) => ({ ...r, totalDays: r.hotDays + r.coldDays }));
+});
+
+const lifecycleMetrics = computed<LifecycleRow[]>(() => {
+  const h = hot.value;
+  if (!h) return [];
+  const c = coldStage.value;
+  const coldOr0 = (v: number | undefined): number => (v != null && v >= 0 ? v : 0);
+  const rows: LifecycleRow[] = [
+    // Metadata first — it has no cold tier and an independent (often
+    // much longer) hot+warm. Putting it at the top of the Metrics
+    // section avoids a tiny zero-cold row dropped between two larger
+    // ones with cold.
+    ...(h.metrics.metadata !== undefined
+      ? [{ label: 'Metadata', hotDays: h.metrics.metadata, coldDays: 0, totalDays: h.metrics.metadata }]
+      : []),
+    { label: 'Minute metric', hotDays: h.metrics.minute, coldDays: coldOr0(c?.metrics.minute), totalDays: 0 },
+    { label: 'Hour metric',   hotDays: h.metrics.hour,   coldDays: coldOr0(c?.metrics.hour),   totalDays: 0 },
+    { label: 'Day metric',    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: `All ${categoryLabel.toLowerCase()} (${rows.length})`,
+      hotDays: first.hotDays,
+      coldDays: first.coldDays,
+      totalDays: first.totalDays,
+    },
+  ];
 }
+const lifecycleRecordsForRender = computed<LifecycleRow[]>(() =>
+  collapseUniform(lifecycleRecords.value, 'Records'),
+);
+const lifecycleMetricsForRender = computed<LifecycleRow[]>(() =>
+  collapseUniform(lifecycleMetrics.value, '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
+ *  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));
+});
+
 </script>
 
 <template>
@@ -72,12 +219,32 @@ function coldLabel(r: Row): string {
       <div>
         <div class="kicker">Operate · Data retention</div>
         <h1>Time To Live</h1>
-        <p class="lede">
-          How long the connected OAP keeps each class of data, reported in whole days.
-          <strong>Records</strong> cover event-style data (traces, logs);
-          <strong>metrics</strong> cover the aggregated tiers (minute / hour / day) plus metadata.
-          The <code>cold</code> value is the cold-stage retention (BanyanDB) — <em>no cold stage</em>
-          means cold storage isn't configured. Read-only; change retention on the OAP side.
+        <!-- The page splits sharply by backend: BanyanDB has a real
+             multi-stage lifecycle (hot+warm + optional cold) so the
+             lede explains the stages + the topbar Cold pill. When
+             the BFF classifies the wire response as `other`, the
+             TTL is a single flat retention per category and the
+             stage vocabulary doesn't apply. -->
+        <p v-if="isBanyanDB" class="lede">
+          How long the connected OAP keeps each class of data, in whole days. Backend:
+          <span class="backend-pill" :class="`is-${backend}`">{{ backendPillLabel }}</span>.
+          BanyanDB ages data through configurable lifecycle stages — <strong>hot</strong>,
+          optionally <strong>warm</strong>, optionally <strong>cold</strong>.
+          <template v-if="coldStage">
+            The topbar <em>Cold</em> pill swaps the read from <strong>hot + warm</strong> to
+            <strong>cold</strong> — <em>it does not union the two</em>, so only enable it when
+            the time range you're looking at is older than the hot+warm boundary.
+          </template>
+          <template v-else>
+            This deployment has no cold lifecycle stage configured — only the hot + warm window
+            is queryable.
+          </template>
+        </p>
+        <p v-else class="lede">
+          How long the connected OAP keeps each class of data, in whole days. Backend:
+          <span class="backend-pill" :class="`is-${backend}`">{{ backendPillLabel }}</span>.
+          This backend reports a single flat retention per category — no hot / warm / cold
+          stages (those are BanyanDB-specific). Read-only; change retention on the OAP side.
         </p>
       </div>
       <button type="button" class="refresh" @click="refetch()">refresh</button>
@@ -87,43 +254,137 @@ function coldLabel(r: Row): string {
       <strong>OAP unreachable</strong>
       <code>{{ data.error }}</code>
       <p class="hint">
-        TTL is read from the query / GraphQL port via <code>getRecordsTTL</code> /
-        <code>getMetricsTTL</code>. Confirm <code>oap.queryUrl</code> points at a live OAP.
+        Couldn't reach the OAP query port. Confirm the OAP host is up and the configured
+        query URL still resolves from this Horizon server.
       </p>
     </div>
 
     <div v-else-if="isLoading && !data" class="empty">Reading data…</div>
 
-    <template v-else>
-      <section class="pane">
-        <header class="pane-head"><h2>Records</h2></header>
-        <div class="grid">
-          <div v-for="row in recordRows" :key="row.label" class="sw-card kpi">
-            <div class="sw-card-head"><h4>{{ row.label }}</h4></div>
-            <div class="kpi-body">
-              <div class="kpi-value">{{ days(row.hot) }}</div>
-              <div class="kpi-label" :class="{ none: row.hasCold && (row.cold ?? -1) < 0 }">
-                {{ coldLabel(row) }}
-              </div>
+    <!-- ── Non-BanyanDB branch ───────────────────────────────────
+         When the BFF classifies the wire response as `other`, the
+         TTL is a single flat retention per category — no stages, no
+         cold tier, no migration. Render just the TTL values; the
+         lifecycle bar + Cold pane + cold tip are all suppressed
+         because they'd describe a model the wire didn't surface. -->
+    <template v-else-if="stages && !isBanyanDB">
+      <section v-if="hot" class="pane">
+        <header class="pane-head">
+          <h2>Retention</h2>
+          <span class="pane-sub">read-only · change on the OAP side</span>
+        </header>
+        <div class="sub-pane">
+          <h3>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>Metrics</h3>
+          <div class="grid">
+            <div v-for="row in metricsRows(hot)" :key="`flat-m-${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>
+        <p class="flat-note">
+          ⓘ Property data is omitted (forever-retained, no TTL reported).
+        </p>
       </section>
+    </template>
+
+    <!-- ── BanyanDB branch ───────────────────────────────────────
+         Full lifecycle layout: per-class timeline bar at the top,
+         then Hot + Warm pane (queried by default), then Cold pane
+         when configured. The cold-window tip appears only while
+         the topbar Cold pill is on. -->
+    <template v-else-if="stages">
+      <!-- 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. -->
+      <section
+        v-if="lifecycleRecordsForRender.length > 0 || lifecycleMetricsForRender.length > 0"
+        class="lifecycle"
+      >
+        <header class="lifecycle__head">
+          <h2>Data lifecycle</h2>
+          <span class="lifecycle__sub">Per-data-class · widths proportional to total retention</span>
+        </header>
+
+        <div v-if="lifecycleRecordsForRender.length > 0" class="lifecycle__group">
+          <h3 class="lifecycle__group-h">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="`Hot + Warm ${r.hotDays} days${r.coldDays > 0 ? `, Cold ${r.coldDays} days` : ''}`"
+            >
+              <div class="lc-seg lc-seg--hot" :style="{ flex: r.hotDays }">
+                <span class="lc-seg__name">Hot + Warm</span>
+                <span class="lc-seg__days">{{ r.hotDays }} d</span>
+              </div>
+              <div v-if="r.coldDays > 0" class="lc-seg lc-seg--cold" :style="{ flex: r.coldDays }">
+                <span class="lc-seg__name">Cold</span>
+                <span class="lc-seg__days">{{ r.coldDays }} d</span>
+              </div>
+            </div>
+            <div class="lc-row__total mono">{{ r.totalDays }} d total</div>
+          </div>
+        </div>
 
-      <section class="pane">
-        <header class="pane-head"><h2>Metrics</h2></header>
-        <div class="grid">
-          <div v-for="row in metricRows" :key="row.label" class="sw-card kpi">
-            <div class="sw-card-head"><h4>{{ row.label }}</h4></div>
-            <div class="kpi-body">
-              <div class="kpi-value">{{ days(row.hot) }}</div>
-              <div class="kpi-label" :class="{ none: row.hasCold && (row.cold ?? -1) < 0 }">
-                {{ coldLabel(row) }}
+        <div v-if="lifecycleMetricsForRender.length > 0" class="lifecycle__group">
+          <h3 class="lifecycle__group-h">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}%` }"
+              :aria-label="`Hot + Warm ${r.hotDays} days${r.coldDays > 0 ? `, Cold ${r.coldDays} days` : ''}`"
+            >
+              <div class="lc-seg lc-seg--hot" :style="{ flex: r.hotDays }">
+                <span class="lc-seg__name">Hot + Warm</span>
+                <span class="lc-seg__days">{{ r.hotDays }} d</span>
+              </div>
+              <div v-if="r.coldDays > 0" class="lc-seg lc-seg--cold" :style="{ flex: r.coldDays }">
+                <span class="lc-seg__name">Cold</span>
+                <span class="lc-seg__days">{{ r.coldDays }} d</span>
               </div>
             </div>
+            <div class="lc-row__total mono">{{ r.totalDays }} d total</div>
           </div>
         </div>
+
+        <p class="lifecycle__note">
+          ⓘ OAP's TTL response collapses <em>hot</em> + <em>warm</em> into one combined value per
+          class — the bar shows the queryable (hot + warm) window, not the per-stage breakdown.
+          BanyanDB migrates between stages in <em>segments</em>, so a record near a boundary may
+          briefly exist in both stages during the migration window. Property data is omitted
+          (forever-retained, no TTL reported).
+        </p>
       </section>
+
+      <!-- Cold-only-read tip. Renders only while the topbar Cold
+           pill is on AND a cold stage is configured — tells the
+           operator which time window will actually return data. -->
+      <div v-if="coldTip" class="cold-tip">
+        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
+          <path d="M12 3v18M3 12h18M5 5l14 14M19 5L5 19" />
+          <path d="M9 6l3-3 3 3M15 18l-3 3-3-3M6 9L3 12l3 3M18 9l3 3-3 3" />
+        </svg>
+        <span>{{ coldTip }}</span>
+      </div>
+      <!-- The per-class Hot+Warm / Cold KPI-card panes that used to
+           live here were redundant with the lifecycle bar above —
+           every number in those cards is already shown on each
+           bar's segment label and the right-side "X d total". -->
     </template>
   </div>
 </template>
@@ -171,6 +432,23 @@ function coldLabel(r: Row): string {
   border-radius: 3px;
   font-size: 11px;
 }
+.backend-pill {
+  display: inline-flex;
+  align-items: center;
+  padding: 1px 7px;
+  border-radius: 10px;
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  letter-spacing: 0.02em;
+  border: 1px solid var(--sw-line-2);
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-1);
+}
+.backend-pill.is-banyandb {
+  color: var(--sw-accent);
+  border-color: var(--sw-accent);
+  background: var(--sw-accent-soft);
+}
 .refresh {
   background: var(--sw-bg-1);
   border: 1px solid var(--sw-line-2);
@@ -185,27 +463,198 @@ function coldLabel(r: Row): string {
   color: var(--sw-fg-0);
 }
 
+/* ── Lifecycle bar ──────────────────────────────────────────────
+   At-a-glance horizontal timeline of ingest → deletion, segmented
+   into Hot+Warm and (when configured) Cold. Segment widths are
+   proportional to days so an operator's eye gets the ratio without
+   reading the labels. Tick row beneath repeats the day numbers in
+   the timeline's coordinate space; the note below names the
+   migration-overlap caveat once instead of repeating it on the
+   per-class cards. */
+.lifecycle {
+  margin-bottom: 22px;
+}
+.lifecycle__head {
+  display: flex;
+  align-items: baseline;
+  gap: 10px;
+  margin-bottom: 8px;
+}
+.lifecycle__head h2 {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  margin: 0;
+  letter-spacing: -0.01em;
+}
+.lifecycle__sub {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  letter-spacing: 0.02em;
+}
+/* Per-group row: [class label] [bar (variable width)] [total]. The
+ * label and total stay aligned across rows via the grid; only the
+ * middle bar varies by class-group retention. */
+.lc-row {
+  display: grid;
+  grid-template-columns: minmax(140px, 220px) 1fr auto;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+.lc-row__label {
+  font-size: 11.5px;
+  color: var(--sw-fg-1);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.lc-row__bar {
+  display: flex;
+  height: 28px;
+  /* Each row's bar starts from the left edge of its column and
+   * extends right by `width` (set inline, proportional to total
+   * retention). A subtle dashed track behind shows the leftover
+   * column space so an operator's eye reads "this class doesn't
+   * reach the max-retention class's tail". */
+  background:
+    repeating-linear-gradient(
+      to right,
+      transparent 0 4px,
+      var(--sw-line-2) 4px 5px
+    );
+  border-radius: 4px;
+  overflow: hidden;
+  min-width: 0;
+  position: relative;
+}
+.lc-row__total {
+  font-size: 11px;
+  font-variant-numeric: tabular-nums;
+  color: var(--sw-fg-3);
+  white-space: nowrap;
+}
+.lc-seg {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 12px;
+  min-width: 0;
+  font-size: 11.5px;
+  font-weight: 600;
+  letter-spacing: 0.02em;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.lc-seg + .lc-seg {
+  border-left: 1px solid var(--sw-line-2);
+}
+.lc-seg--hot {
+  /* Hot+Warm reads as the "live" tier; use the accent's soft tint
+     plus the accent color for text so the bar reads warmly on
+     dark + light themes alike. */
+  background: var(--sw-accent-soft);
+  color: var(--sw-accent-2);
+}
+.lc-seg--cold {
+  /* Cold is opt-in; muted background + slate-ish text reads as
+     archived without competing with the hot segment. */
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+}
+.lc-seg__days {
+  font-family: var(--sw-mono);
+  font-variant-numeric: tabular-nums;
+  opacity: 0.9;
+}
+.lifecycle__note {
+  margin: 10px 0 0;
+  padding: 8px 10px;
+  background: var(--sw-bg-1);
+  border: 1px dashed var(--sw-line-2);
+  border-radius: 6px;
+  font-size: 11.5px;
+  color: var(--sw-fg-2);
+  line-height: 1.55;
+}
+.lifecycle__note em {
+  color: var(--sw-fg-0);
+  font-style: normal;
+  font-weight: 600;
+}
+
+/* Non-BanyanDB footer note — small reminder under the flat-
+ * retention pane. Less prose than the BanyanDB one because there
+ * are no stages or migrations to caveat. */
+.flat-note {
+  margin: 14px 0 0;
+  font-size: 11.5px;
+  color: var(--sw-fg-3);
+  line-height: 1.5;
+}
+
+.cold-tip {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 18px;
+  padding: 8px 12px;
+  background: var(--sw-accent-soft);
+  border: 1px solid var(--sw-accent);
+  border-radius: 6px;
+  color: var(--sw-accent);
+  font-size: 11.5px;
+  line-height: 1.5;
+}
+
 .pane {
   margin-bottom: 26px;
 }
+.pane.is-cold {
+  /* Faint cool tint so the cold pane is visually distinct from
+   * hot+warm without shouting. */
+  border-left: 2px solid var(--sw-accent);
+  padding-left: 12px;
+}
 .pane-head {
   display: flex;
-  align-items: center;
+  align-items: baseline;
   gap: 10px;
   margin-bottom: 10px;
 }
 .pane-head h2 {
-  font-size: 13px;
+  font-size: 14px;
   font-weight: 600;
   color: var(--sw-fg-0);
   margin: 0;
   letter-spacing: -0.01em;
 }
+.pane-sub {
+  font-size: 10.5px;
+  color: var(--sw-fg-3);
+  letter-spacing: 0.02em;
+}
+.pane-sub strong {
+  color: var(--sw-accent);
+  font-weight: 600;
+}
 
+.sub-pane {
+  margin: 6px 0 16px;
+}
+.sub-pane h3 {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--sw-fg-2);
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  margin: 0 0 6px;
+}
 .grid {
   display: grid;
-  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
-  gap: 12px;
+  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+  gap: 10px;
 }
 .kpi .sw-card-head {
   display: flex;
@@ -216,26 +665,16 @@ function coldLabel(r: Row): string {
   flex: 1;
 }
 .kpi-body {
-  padding: 14px 12px;
+  padding: 12px 10px;
 }
 .kpi-value {
-  font-size: 24px;
+  font-size: 22px;
   font-weight: 600;
   letter-spacing: -0.02em;
   color: var(--sw-fg-0);
   font-variant-numeric: tabular-nums;
   line-height: 1.1;
 }
-.kpi-label {
-  margin-top: 4px;
-  font-size: 11px;
-  color: var(--sw-fg-2);
-  font-variant-numeric: tabular-nums;
-}
-.kpi-label.none {
-  color: var(--sw-fg-3);
-  font-style: italic;
-}
 
 .empty {
   padding: 14px;
diff --git a/apps/ui/src/layer/LayerShell.vue b/apps/ui/src/layer/LayerShell.vue
index 372a132..d5bc582 100644
--- a/apps/ui/src/layer/LayerShell.vue
+++ b/apps/ui/src/layer/LayerShell.vue
@@ -38,6 +38,7 @@ import LayerServiceSelector from './LayerServiceSelector.vue';
 import { metricMeta } from '@/utils/metricCatalog';
 import { colorForMetric } from '@/utils/metricColor';
 import { useLayerLanding } from '@/layer/useLayerLanding';
+import { useTimeRangeStore } from '@/controls/timeRange';
 import { useLayers, firstLayerTab } from '@/shell/useLayers';
 import { layerContentToDef, type LayerTemplateContent } from '@/shell/layerFromTemplate';
 import { useSelectedService } from '@/layer/useSelectedService';
@@ -202,7 +203,20 @@ const safeCfg = computed(() => cfg.value?.landing ?? {
   columns: [],
   style: 'table' as const,
 });
-const landing = useLayerLanding(safeLayer, safeCfg);
+// Header KPIs honor the topbar time picker so they line up with what
+// the dashboard widgets below are showing — without this, the header
+// always queried the BFF's last-60min fallback while the body honored
+// the picker, leaving the operator with two contradicting summaries
+// (see "Cascade-clear, then load" in CLAUDE.md). On per-tab opt-out
+// pages (traces, profiling) the picker is greyed but still carries its
+// last value; the header summarizes the layer for that value.
+const timeRange = useTimeRangeStore();
+const headerRange = computed(() => ({
+  step: timeRange.step,
+  startMs: timeRange.range.startMs,
+  endMs: timeRange.range.endMs,
+}));
+const landing = useLayerLanding(safeLayer, safeCfg, headerRange);
 
 // Cascade-clear for the header: the instant the layer changes (menu
 // click), reset to "no data" so the KPI strip + selected-service values
diff --git a/apps/ui/src/layer/logs/LayerLogsView.vue b/apps/ui/src/layer/logs/LayerLogsView.vue
index 7b18f66..d40f3de 100644
--- a/apps/ui/src/layer/logs/LayerLogsView.vue
+++ b/apps/ui/src/layer/logs/LayerLogsView.vue
@@ -466,8 +466,14 @@ function fmtTime(ts: number): string {
   return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
 }
 function fmtDate(ts: number): string {
+  // Numeric MM-DD in the operator's browser-local TZ. Matches the
+  // topbar's compact date chip (`05-12 03h → 06h`) and stays
+  // locale-independent — `toLocaleDateString` without an explicit
+  // locale picks up the system locale (e.g. `5月08日` on zh-CN), which
+  // looked inconsistent next to the rest of the dense English UI.
   const d = new Date(ts);
-  return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' });
+  const pad = (n: number) => String(n).padStart(2, '0');
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
 }
 /**
  * Render a one-line inline preview. Length is NOT capped here — the
@@ -616,9 +622,12 @@ function fmtAxisTime(ts: number): string {
 /** Open the trace in the global popout overlay rather than navigating
  *  to the Traces tab — keeps the operator in the log stream, lets them
  *  scan the waterfall + close it back to where they were without
- *  losing the keyword filter / pagination state. */
-function jumpToTrace(traceId: string): void {
-  openTrace(traceId);
+ *  losing the keyword filter / pagination state. The row's timestamp
+ *  is passed as a hint so BanyanDB's `queryTrace` looks in the right
+ *  window — without this, OAP searches only the last 1 day and any
+ *  trace older than that (cold-tier, etc.) silently fails to load. */
+function jumpToTrace(traceId: string, ts?: number): void {
+  openTrace(traceId, ts);
 }
 </script>
 
@@ -868,7 +877,7 @@ function jumpToTrace(traceId: string): void {
                 >{{ parseServiceName(r.serviceName).group }}</span>
                 {{ r.serviceName ? parseServiceName(r.serviceName).base : '—' }}
               </span>
-              <span v-if="r.traceId" class="lg-trace mono" @click.stop="jumpToTrace(r.traceId!)">↗ trace</span>
+              <span v-if="r.traceId" class="lg-trace mono" @click.stop="jumpToTrace(r.traceId!, r.timestamp)">↗ trace</span>
               <!-- Format chip + flat content preview. Chip is always
                    rendered so the operator can tell at-a-glance which
                    rows are JSON / YAML / plain text. Preview is single
@@ -919,7 +928,7 @@ function jumpToTrace(traceId: string): void {
               v-if="popoutRow.traceId"
               class="sw-btn small"
               type="button"
-              @click="jumpToTrace(popoutRow.traceId!)"
+              @click="jumpToTrace(popoutRow.traceId!, popoutRow.timestamp)"
             >↗ trace</button>
             <button class="sw-btn small ghost" type="button" @click="closePopout">×</button>
           </div>
diff --git a/apps/ui/src/layer/service-map/useLayerTopology.ts b/apps/ui/src/layer/service-map/useLayerTopology.ts
index a9d1944..3a43b6e 100644
--- a/apps/ui/src/layer/service-map/useLayerTopology.ts
+++ b/apps/ui/src/layer/service-map/useLayerTopology.ts
@@ -17,13 +17,18 @@
 
 /**
  * vue-query wrapper around `GET /api/layer/:key/topology`. The query
- * fires whenever the selected service or BFS depth changes; rerouting
- * a layer (e.g. via the picker) is enough to refresh.
+ * fires whenever the selected service, BFS depth, OR the global
+ * topbar time picker changes — the picker is part of the queryKey so
+ * the operator sees topology metrics that line up with whatever
+ * window they're looking at (including cold-stage windows when the
+ * Cold pill is on; the X-Horizon-Cold-Stage header is appended by
+ * the api-client interceptor).
  */
 
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../../controls/useAutoRefreshSubscribe';
+import { useTimeRangeStore } from '../../controls/timeRange';
 import { bffClient } from '@/api/client';
 
 export function useLayerTopology(
@@ -31,10 +36,24 @@ export function useLayerTopology(
   service: Ref<string | null>,
   depth: Ref<number>,
 ) {
+  const timeRange = useTimeRangeStore();
+  // Re-resolve range / step on every read so the queryKey changes on
+  // picker flips. A stable triplet (step + ms-rounded bounds) prevents
+  // identity-thrash on every store tick.
+  const rangeKey = computed(() => ({
+    step: timeRange.step,
+    startMs: timeRange.range.startMs,
+    endMs: timeRange.range.endMs,
+  }));
   const q = useQuery({
-    queryKey: ['layer-topology', layerKey, service, depth],
+    queryKey: ['layer-topology', layerKey, service, depth, rangeKey],
     queryFn: () =>
-      bffClient.layer.topology(layerKey.value, service.value ?? undefined, depth.value),
+      bffClient.layer.topology(
+        layerKey.value,
+        service.value ?? undefined,
+        depth.value,
+        rangeKey.value,
+      ),
     enabled: computed(() => layerKey.value.length > 0),
     staleTime: 30_000,
   });
diff --git a/apps/ui/src/layer/traces/TracePopout.vue b/apps/ui/src/layer/traces/TracePopout.vue
index fcfc920..587569d 100644
--- a/apps/ui/src/layer/traces/TracePopout.vue
+++ b/apps/ui/src/layer/traces/TracePopout.vue
@@ -36,11 +36,16 @@ import { useTracePopout } from '@/layer/traces/useTracePopout';
 import { componentIconOrNull } from '@/layer/service-map/useTopologyIcons';
 import { fmtMetric } from '@/utils/formatters';
 
-const { openTraceId, openTrace, closeTrace } = useTracePopout();
+const { openTraceId, openTraceAtMs, openTrace, closeTrace } = useTracePopout();
 
 const sourceRef = computed<'native'>(() => 'native');
 const traceIdRef = computed<string | null>(() => openTraceId.value);
-const { nativeDetail, isFetching } = useTraceDetail(traceIdRef, sourceRef);
+// `openTraceAtMs` is set by callers that know the trace's approximate
+// time (e.g. clicking the trace icon on a log row). The detail
+// composable widens the BanyanDB lookup window around it so cold-tier
+// trace IDs resolve.
+const traceAtRef = computed<number | null>(() => openTraceAtMs.value);
+const { nativeDetail, isFetching } = useTraceDetail(traceIdRef, sourceRef, traceAtRef);
 
 const spans = computed<NativeSpan[]>(() => nativeDetail.value?.spans ?? []);
 
diff --git a/apps/ui/src/layer/traces/useLayerTraces.ts b/apps/ui/src/layer/traces/useLayerTraces.ts
index 66c4534..4cf1e86 100644
--- a/apps/ui/src/layer/traces/useLayerTraces.ts
+++ b/apps/ui/src/layer/traces/useLayerTraces.ts
@@ -113,10 +113,37 @@ export function useLayerTraces(layerKey: Ref<string>, params: TraceListParams) {
   };
 }
 
-export function useTraceDetail(traceId: Ref<string | null>, source: Ref<'native' | 'zipkin'>) {
+/** Wide search window around a timestamp hint (12 hours on either
+ *  side, HOUR step). Wide enough to absorb agent clock drift and the
+ *  log-record vs trace-span timing gap, narrow enough to stay inside
+ *  BanyanDB's HOUR-precision cap. */
+const TRACE_LOOKUP_HALF_WINDOW_MS = 12 * 60 * 60 * 1000;
+
+export function useTraceDetail(
+  traceId: Ref<string | null>,
+  source: Ref<'native' | 'zipkin'>,
+  /** Optional timestamp the trace is known to live near (e.g. a log
+   *  row's timestamp). When present, the BFF widens the BanyanDB
+   *  lookup to ±12h around it; paired with the cold-stage header,
+   *  this lets cold-tier trace IDs resolve. When null, the BFF
+   *  defaults to OAP's last-1-day `queryTrace` window. */
+  atMs?: Ref<number | null>,
+) {
+  const rangeKey = computed(() => atMs?.value ?? null);
   const q = useQuery<TraceDetailResponse>({
-    queryKey: ['trace-detail', traceId, source],
-    queryFn: () => bffClient.trace.detail(traceId.value!, source.value),
+    queryKey: ['trace-detail', traceId, source, rangeKey],
+    queryFn: () => {
+      const at = atMs?.value;
+      const range =
+        typeof at === 'number' && at > 0
+          ? {
+              startMs: at - TRACE_LOOKUP_HALF_WINDOW_MS,
+              endMs: at + TRACE_LOOKUP_HALF_WINDOW_MS,
+              step: 'HOUR' as const,
+            }
+          : undefined;
+      return bffClient.trace.detail(traceId.value!, source.value, range);
+    },
     enabled: computed(() => !!traceId.value),
     staleTime: 60_000,
   });
diff --git a/apps/ui/src/layer/traces/useTracePopout.ts b/apps/ui/src/layer/traces/useTracePopout.ts
index ec32eb1..bf80eac 100644
--- a/apps/ui/src/layer/traces/useTracePopout.ts
+++ b/apps/ui/src/layer/traces/useTracePopout.ts
@@ -33,11 +33,16 @@
 import { computed } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 
-/** Query-string key. Any URL with this key auto-opens the trace
- *  popout — used for shareable links. The trace-tab list-row click
- *  also writes this key, and the global topbar treats opting-out
- *  routes the same way. */
+/** Query-string keys. Any URL with TRACE_POPOUT_QUERY auto-opens the
+ *  trace popout — shareable. TRACE_POPOUT_AT carries the trace's
+ *  approximate timestamp (epoch ms) so the popout can widen the
+ *  BanyanDB lookup window — needed for trace IDs older than the
+ *  default 1-day `queryTrace` search (most often: clicking a trace
+ *  link on a 12d-old log row with the Cold pill on). Callers without
+ *  a timestamp hint (paste-the-ID-in-the-URL flows) omit it; the
+ *  popout falls back to the default 1-day search. */
 export const TRACE_POPOUT_QUERY = 'traceId';
+export const TRACE_POPOUT_AT = 'traceAt';
 
 export function useTracePopout() {
   const route = useRoute();
@@ -47,20 +52,33 @@ export function useTracePopout() {
     const v = route.query[TRACE_POPOUT_QUERY];
     return typeof v === 'string' && v.length > 0 ? v : null;
   });
+  const openTraceAtMs = computed<number | null>(() => {
+    const v = route.query[TRACE_POPOUT_AT];
+    if (typeof v !== 'string') return null;
+    const n = Number(v);
+    return Number.isFinite(n) && n > 0 ? n : null;
+  });
 
-  function openTrace(id: string): void {
+  function openTrace(id: string, atMs?: number): void {
     if (!id) return;
-    void router.push({
-      path: route.path,
-      query: { ...route.query, [TRACE_POPOUT_QUERY]: id },
-    });
+    const next: Record<string, string | (string | null)[] | null | undefined> = {
+      ...route.query,
+      [TRACE_POPOUT_QUERY]: id,
+    };
+    if (typeof atMs === 'number' && Number.isFinite(atMs) && atMs > 0) {
+      next[TRACE_POPOUT_AT] = String(atMs);
+    } else {
+      delete next[TRACE_POPOUT_AT];
+    }
+    void router.push({ path: route.path, query: next });
   }
 
   function closeTrace(): void {
     const q = { ...route.query };
     delete q[TRACE_POPOUT_QUERY];
+    delete q[TRACE_POPOUT_AT];
     void router.replace({ path: route.path, query: q });
   }
 
-  return { openTraceId, openTrace, closeTrace };
+  return { openTraceId, openTraceAtMs, openTrace, closeTrace };
 }
diff --git a/apps/ui/src/render/overview/useOverviewDashboard.ts b/apps/ui/src/render/overview/useOverviewDashboard.ts
index 07fd937..fd731e4 100644
--- a/apps/ui/src/render/overview/useOverviewDashboard.ts
+++ b/apps/ui/src/render/overview/useOverviewDashboard.ts
@@ -19,6 +19,7 @@ import { computed, type Ref } from 'vue';
 import { useQueries, useQuery } from '@tanstack/vue-query';
 import type { LandingConfig, OverviewDashboard, OverviewWidget } from '@skywalking-horizon-ui/api-client';
 import { bffClient } from '@/api/client';
+import { useTimeRangeStore } from '@/controls/timeRange';
 
 /**
  * Resolved value for one overview widget. The renderer reads
@@ -107,14 +108,27 @@ export function useOverviewDashboard(idRef: Ref<string>) {
   const widgets = computed<OverviewWidget[]>(() => dash.data.value?.dashboard.widgets ?? []);
   const layerRequests = computed(() => groupByLayer(widgets.value));
 
+  // The topbar time picker is part of every overview query so flipping
+  // the time / cold pills refires the per-layer landing calls instead
+  // of serving the previous window's cached aggregates. We forward the
+  // raw step+startMs+endMs to the BFF and also stamp them into the
+  // queryKey for cache scoping.
+  const timeRange = useTimeRangeStore();
+  const rangeKey = computed(() => ({
+    step: timeRange.step,
+    startMs: timeRange.range.startMs,
+    endMs: timeRange.range.endMs,
+  }));
+
   // One landing call per referenced layer. Bundling all that layer's
   // MQEs in one request keeps the round-trip count to N, where N is the
   // distinct layer count in the dashboard — usually 1–3.
   const layerQueries = useQueries({
     queries: computed(() => {
       const entries = Array.from(layerRequests.value.entries());
+      const range = rangeKey.value;
       return entries.map(([layer, reqs]) => ({
-        queryKey: ['overview-dashboard-data', idRef.value, layer],
+        queryKey: ['overview-dashboard-data', idRef.value, layer, range],
         queryFn: () => {
           /* Service-count KPIs read from `aggregates.serviceCount`
            * — strip them from the MQE column list to avoid sending
@@ -137,7 +151,7 @@ export function useOverviewDashboard(idRef: Ref<string>) {
               unit: r.unit,
             })),
           };
-          return bffClient.layer.landing(layer, cfg).then((res) => ({
+          return bffClient.layer.landing(layer, cfg, range).then((res) => ({
             layer,
             reqs,
             mqeReqs,
diff --git a/apps/ui/src/render/widgets/AlarmsWidget.vue b/apps/ui/src/render/widgets/AlarmsWidget.vue
index 8973902..dcfc2ee 100644
--- a/apps/ui/src/render/widgets/AlarmsWidget.vue
+++ b/apps/ui/src/render/widgets/AlarmsWidget.vue
@@ -86,6 +86,19 @@ const windowMs = computed<number>(
   () => cfgQuery.data.value?.defaultWindowMs ?? FALLBACK_WINDOW_MS,
 );
 
+/** Compact human label for the resolved window — surfaced in the
+ *  widget title so operators can tell "no alarms in the last 10m"
+ *  apart from "no alarms in the last hour". Sub-minute / sub-hour
+ *  numbers stay round (we never ship odd values like 13m). */
+const windowLabel = computed<string>(() => {
+  const ms = windowMs.value;
+  const min = Math.round(ms / 60_000);
+  if (min < 60) return `last ${min}m`;
+  const h = Math.round(min / 60);
+  if (h < 24) return `last ${h}h`;
+  return `last ${Math.round(h / 24)}d`;
+});
+
 const alarmsQuery = useQuery({
   /* Layer is keyed only in new-API mode — legacy mode ignores it
    * and re-uses a single all-layers cache across every overview.
@@ -178,7 +191,10 @@ function incidentEntity(i: AlarmIncident): string {
 <template>
   <section class="sw-card alarms-widget">
     <header>
-      <h4>{{ title }}</h4>
+      <!-- Title shows the resolved window (e.g. "Active alarms ·
+           last 10m") so an empty rail isn't mistaken for "no
+           alarms" overall — the operator sees the slice. -->
+      <h4>{{ title }} <span class="window-tag">· {{ windowLabel }}</span></h4>
       <span class="total mono" :class="{ active: activeIncidents > 0 }">
         {{ activeIncidents }}{{ truncated ? '+' : '' }} active
       </span>
@@ -191,7 +207,7 @@ function incidentEntity(i: AlarmIncident): string {
     <div class="body">
       <div v-if="alarmsQuery.isPending.value" class="empty">loading…</div>
       <div v-else-if="rows.length === 0" class="empty">
-        No alarms in the last 60m{{ layer ? ` for ${layer}` : '' }}.
+        No alarms in the {{ windowLabel }}{{ layer ? ` for ${layer}` : '' }}.
       </div>
       <div v-else class="rows">
         <div
@@ -220,6 +236,9 @@ header {
   border-bottom: 1px solid var(--sw-line);
 }
 h4 { margin: 0; font-size: 12px; font-weight: 600; color: var(--sw-fg-0); }
+/* Smaller, muted window suffix so the title still reads as the
+ * widget name (matches the dense topbar/picker vocabulary). */
+.window-tag { font-size: 11px; font-weight: 400; color: var(--sw-fg-3); margin-left: 4px; }
 .total {
   font-size: 11px;
   color: var(--sw-fg-3);
diff --git a/apps/ui/src/shell/AppShell.vue b/apps/ui/src/shell/AppShell.vue
index b5f941a..bf10b7e 100644
--- a/apps/ui/src/shell/AppShell.vue
+++ b/apps/ui/src/shell/AppShell.vue
@@ -21,6 +21,7 @@ import AppSidebar from './AppSidebar.vue';
 import AppTopbar from './AppTopbar.vue';
 import DebugEventPanel from './DebugEventPanel.vue';
 import GlobalConnectivityBanner from './GlobalConnectivityBanner.vue';
+import ColdStageTrapBanner from './ColdStageTrapBanner.vue';
 import PreviewModeBanner from './PreviewModeBanner.vue';
 import TracePopout from '@/layer/traces/TracePopout.vue';
 import ZipkinTracePopout from '@/layer/traces/ZipkinTracePopout.vue';
@@ -115,6 +116,12 @@ const { collapsed: sidebarCollapsed } = useSidebar();
            (`:12800`) poll reports unreachable. Admin-port (`:17128`)
            failures render per-page via AdminFeatureWarning, not here. -->
       <GlobalConnectivityBanner />
+      <!-- Cold-only-with-recent-range warning. Renders only on BanyanDB
+           when the operator has the Cold pill ON AND the picked time
+           range overlaps hot+warm (where cold returns empty). Loudly
+           tells the operator why widgets went blank and offers a
+           one-click "turn Cold off". -->
+      <ColdStageTrapBanner />
       <!-- Notice shown only while a page is in ?mode=preview. -->
       <PreviewModeBanner />
       <!-- Shell-level init placeholder. Visible until the layer
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index 8a97d59..177a11a 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -231,7 +231,7 @@ const sections: NavSection[] = [
       { icon: 'alert', label: 'Alerting rules', to: '/operate/alerting-rules', verb: 'alarm-rule:read' },
       {
         icon: 'set',
-        label: 'DSL Management',
+        label: 'DSL management',
         // No standalone landing — `to` jumps to the first rule page so
         // the L1 itself is clickable; activeWhen covers all DSL routes.
         to: '/operate/dsl/otel-rules',
@@ -256,7 +256,7 @@ const sections: NavSection[] = [
         activeWhen: (p) => p === '/operate/live-debug' || /^\/operate\/live-debug\/(mal|lal|oal)(\/|$)/.test(p),
       },
       { icon: 'event', label: 'Capture history', to: '/operate/live-debug/history', verb: 'live-debug:read' },
-      { icon: 'metric', label: 'Metrics Inspect', to: '/operate/inspect', verb: 'inspect:read' },
+      { icon: 'metric', label: 'Metrics inspect', to: '/operate/inspect', verb: 'inspect:read' },
     ],
   },
   {
diff --git a/apps/ui/src/shell/AppTopbar.vue b/apps/ui/src/shell/AppTopbar.vue
index 02f0396..8530f26 100644
--- a/apps/ui/src/shell/AppTopbar.vue
+++ b/apps/ui/src/shell/AppTopbar.vue
@@ -18,9 +18,11 @@
 import { computed, ref, watch } from 'vue';
 import { RouterLink, useRoute } from 'vue-router';
 import Icon from '@/components/icons/Icon.vue';
+import { useQueryClient } from '@tanstack/vue-query';
 import { useOapInfo } from '@/shell/useOapInfo';
 import { useAlarmCount } from '@/shell/useAlarmCount';
 import { useAutoRefreshStore } from '@/controls/autoRefresh';
+import { useColdStageStore } from '@/controls/coldStage';
 import { useTimeRangeStore, TIME_PRESETS, STEP_LIMITS, isValidRange, type TimeStep } from '@/controls/timeRange';
 import { useThemeStore, AVAILABLE_THEMES, type ThemeId } from '@/state/theme';
 import { useAuthStore } from '@/state/auth';
@@ -79,8 +81,34 @@ function onThemeChipBlur(e: FocusEvent): void {
 
 const route = useRoute();
 
-const { info, reachable, tzOffsetLabel, healthState } = useOapInfo();
+const { info, reachable, tzOffsetLabel, healthState, backend } = useOapInfo();
 const auth = useAuthStore();
+
+// "Query cold stage" — BanyanDB-only. The chip renders only when the
+// connected OAP uses BanyanDB.
+//
+// IMPORTANT SEMANTICS: OAP's `Duration.coldStage: true` REPLACES the
+// hot+warm read, it does not augment it (see the comment on
+// `Duration.coldStage` in OAP's query-protocol). Turning the pill on
+// while looking at a recent dashboard makes every widget go empty,
+// because the recent window doesn't exist in cold yet. Only flip it
+// on when the operator is asking for data older than the hot+warm
+// TTL boundary (Operate → Time To Live shows the values per class).
+//
+// Flipping invalidates every tanstack-query cache so subscribers
+// refetch with the new header instead of serving stale data from
+// the previous stage.
+const cold = useColdStageStore();
+const queryClient = useQueryClient();
+const showColdChip = computed<boolean>(() => backend.value === 'banyandb');
+function toggleCold(): void {
+  cold.toggle(queryClient);
+}
+const coldChipTooltip = computed<string>(() =>
+  cold.enabled
+    ? 'Reading cold-stage data only. ⚠ Recent windows return empty — cold doesn\'t hold recent data. Click to switch back to hot+warm.'
+    : 'Reading hot+warm data (default). Click to switch to the cold stage instead — use only when the time range is older than the hot+warm TTL (see Operate → Time To Live).',
+);
 // The Cluster Status page is maintainer-tier; only link the chip there
 // when the user can actually read it (matches the route's verb gate).
 const canViewCluster = computed(() => auth.hasVerb('cluster:read'));
@@ -346,6 +374,27 @@ function draftToMs(step: TimeStep, side: 'start' | 'end'): number | null {
   const dt = new Date(v);
   return Number.isNaN(dt.getTime()) ? null : dt.getTime();
 }
+/** Seed the custom-range form for `step`. Pulls from the currently
+ *  applied range when the step matches and the window fits; otherwise
+ *  falls back to "half the step's max, ending now". Called both from
+ *  the manual "Custom range…" click AND from the auto-expand on
+ *  picker-open path below — keeps the seed logic in one place. */
+function seedCustomDraft(step: TimeStep): void {
+  const lim = STEP_LIMITS[step];
+  let endMs: number;
+  let startMs: number;
+  const cur = timeRange.range;
+  const fits = step === timeRange.step && cur.endMs > cur.startMs && cur.endMs - cur.startMs <= lim.maxMs;
+  if (fits) {
+    startMs = cur.startMs;
+    endMs = cur.endMs;
+  } else {
+    endMs = Date.now();
+    startMs = endMs - Math.floor(lim.maxMs / 2);
+  }
+  setDraftFromMs(step, 'start', startMs);
+  setDraftFromMs(step, 'end', endMs);
+}
 function openCustom(step: TimeStep): void {
   customError.value = null;
   if (customOpenStep.value === step) {
@@ -353,15 +402,24 @@ function openCustom(step: TimeStep): void {
     return;
   }
   customOpenStep.value = step;
-  // Seed the form with a sensible default range for the precision —
-  // half the max, ending now. Operators then nudge whichever bound
-  // they actually care about.
-  const lim = STEP_LIMITS[step];
-  const endMs = Date.now();
-  const startMs = endMs - Math.floor(lim.maxMs / 2);
-  setDraftFromMs(step, 'start', startMs);
-  setDraftFromMs(step, 'end', endMs);
+  seedCustomDraft(step);
 }
+
+// Auto-expand the custom-range form whenever the picker opens AND the
+// currently-applied range is a custom one — so re-opening lands the
+// operator straight on the inputs they last edited, on the matching
+// precision tab. For preset ranges (Last 15m, etc.) we leave the
+// custom form collapsed so the preset list is the focal point.
+watch(timeMenuOpen, (open) => {
+  if (!open) return;
+  activeStepTab.value = timeRange.step;
+  if (timeRange.presetId === 'custom') {
+    customOpenStep.value = timeRange.step;
+    seedCustomDraft(timeRange.step);
+  } else {
+    customOpenStep.value = null;
+  }
+});
 function submitCustom(step: TimeStep): void {
   const startMs = draftToMs(step, 'start');
   const endMs = draftToMs(step, 'end');
@@ -428,6 +486,17 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
              shows it prominently. Non-cluster:read users get a static
              chip (no link to the maintainer-only Cluster page). -->
       </component>
+      <button
+        v-if="showColdChip"
+        type="button"
+        class="sw-btn cold-chip"
+        :class="{ 'is-on': cold.enabled }"
+        :title="coldChipTooltip"
+        @click="toggleCold"
+      >
+        <Icon name="snowflake" :size="11" />
+        <span>{{ cold.enabled ? 'Cold only' : 'Cold' }}</span>
+      </button>
       <div ref="timeClusterEl" class="time-cluster">
         <button
           type="button"
@@ -730,6 +799,34 @@ function formatRangeStamp(ms: number, step: TimeStep): string {
   transition: opacity 0.15s ease, transform 0.15s ease;
 }
 
+/* ── Cold-stage chip (BanyanDB only) ─────────────────────────── */
+/* Snowflake pill that sits to the left of the time picker. Off state
+ * is muted (matches the other off-state chips); on state uses a cool
+ * cyan tint so it visually pops — operators are intentionally drawn
+ * to "you are currently including cold data" because it changes which
+ * window of history is in play. */
+.cold-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 11px;
+  padding: 0 8px;
+  cursor: pointer;
+  color: var(--sw-fg-2);
+}
+.cold-chip:hover {
+  color: var(--sw-fg-0);
+}
+.cold-chip.is-on {
+  background: var(--sw-accent-soft);
+  border-color: var(--sw-accent);
+  color: var(--sw-accent);
+  font-weight: 600;
+}
+.cold-chip.is-on :deep(svg) {
+  color: var(--sw-accent);
+}
+
 /* ── Global time-range picker ─────────────────────────────────── */
 .time-cluster {
   position: relative;
diff --git a/apps/ui/src/shell/ColdStageTrapBanner.vue b/apps/ui/src/shell/ColdStageTrapBanner.vue
new file mode 100644
index 0000000..43d4754
--- /dev/null
+++ b/apps/ui/src/shell/ColdStageTrapBanner.vue
@@ -0,0 +1,158 @@
+<!--
+  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.
+-->
+<!--
+  Loud warning strip for the cold-stage "replace, not augment" trap.
+
+  Mounts under the topbar on every shell route. Renders ONLY when ALL
+  of the following hold:
+
+    1. backend === 'banyandb' (otherwise the toggle isn't visible
+       and there's no trap to warn about).
+    2. The operator has the Cold pill ON (querying ONLY cold-stage
+       data — see comments in `controls/coldStage.ts`).
+    3. The currently-picked time window's END is newer than the
+       hot+warm cutoff reported in TTL (i.e. the window is at least
+       partly in hot+warm, where cold returns nothing).
+
+  Why this exists despite the topbar tooltip: operators don't read
+  tooltips mid-investigation. They read inline-banner copy because it
+  changes the page. The trap is wire-true ("Duration.coldStage: true"
+  REPLACES the hot+warm read) and every demo session has bitten on
+  it; tooltip alone isn't enough.
+
+  The cutoff is derived from `stages.hot.metrics.minute` — the most
+  operator-relevant data class for dashboard / metric queries.
+  Per-class boundaries vary slightly; we err on the side of warning
+  (use the LOWEST hot+warm days across classes so the banner fires
+  whenever ANY class would go empty).
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useColdStageStore } from '@/controls/coldStage';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import { useTtl } from '@/features/operate/ttl/useTtl';
+import { useOapInfo } from '@/shell/useOapInfo';
+
+const cold = useColdStageStore();
+const timeRange = useTimeRangeStore();
+const { backend } = useOapInfo();
+const { data: ttl } = useTtl();
+
+/** Smallest hot+warm window (in days) across the data classes the
+ *  dashboard/landing/topology routes actually query — picking the min
+ *  means we warn whenever ANY class would return empty for the
+ *  current window. */
+const hotPlusWarmDays = computed<number | null>(() => {
+  const hot = ttl.value?.stages?.hot;
+  if (!hot) return null;
+  const cands: number[] = [
+    hot.metrics.minute,
+    hot.metrics.hour,
+    hot.metrics.day,
+    hot.records.normal,
+    hot.records.trace,
+    hot.records.log,
+  ].filter((n) => typeof n === 'number' && Number.isFinite(n) && n > 0);
+  if (cands.length === 0) return null;
+  return Math.min(...cands);
+});
+
+/** True when the current time-range END is newer than the hot+warm
+ *  cutoff — i.e. the picked window touches data that isn't in cold
+ *  yet. We compute against `endMs` (not start) because if the END is
+ *  inside hot+warm, the cold-only read is at least partly empty;
+ *  showing the warning then is the right move. */
+const rangeOverlapsHotWarm = computed<boolean>(() => {
+  const days = hotPlusWarmDays.value;
+  if (days == null) return false;
+  const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
+  return timeRange.range.endMs > cutoffMs;
+});
+
+const visible = computed<boolean>(
+  () =>
+    backend.value === 'banyandb' &&
+    cold.enabled &&
+    rangeOverlapsHotWarm.value,
+);
+
+function turnColdOff(): void {
+  // Match the toggle path the topbar uses — invalidates queries so
+  // subscribers refetch in hot+warm mode immediately.
+  cold.set(false);
+}
+</script>
+
+<template>
+  <div v-if="visible" role="alert" class="cs-trap">
+    <span class="cs-trap__icon" aria-hidden="true">
+      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+        <path d="M12 3v18M3 12h18M5 5l14 14M19 5L5 19" />
+      </svg>
+    </span>
+    <span class="cs-trap__text">
+      <strong>Cold-only read is active</strong> — your time range is within the last
+      <b>{{ hotPlusWarmDays }} d</b> (hot + warm), where the cold stage returns nothing.
+      Pick a window older than <b>{{ hotPlusWarmDays }} days</b> ago, or turn the Cold pill off.
+    </span>
+    <button type="button" class="cs-trap__action" @click="turnColdOff">Turn Cold off</button>
+  </div>
+</template>
+
+<style scoped>
+.cs-trap {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 6px 14px;
+  background: var(--sw-warn-soft, rgba(234, 179, 8, 0.12));
+  border-bottom: 1px solid var(--sw-warn, rgba(234, 179, 8, 0.55));
+  color: var(--sw-fg-0);
+  font-size: 12px;
+  line-height: 1.5;
+}
+.cs-trap__icon {
+  display: inline-flex;
+  color: var(--sw-warn, #eab308);
+}
+.cs-trap__text {
+  flex: 1;
+  color: var(--sw-fg-1);
+}
+.cs-trap__text strong {
+  color: var(--sw-warn, #eab308);
+  margin-right: 4px;
+}
+.cs-trap__text b {
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.cs-trap__action {
+  background: transparent;
+  border: 1px solid var(--sw-warn, #eab308);
+  color: var(--sw-warn, #eab308);
+  font: inherit;
+  font-size: 11px;
+  font-weight: 600;
+  padding: 3px 10px;
+  border-radius: 4px;
+  cursor: pointer;
+}
+.cs-trap__action:hover {
+  background: var(--sw-warn-soft, rgba(234, 179, 8, 0.18));
+}
+</style>
diff --git a/apps/ui/src/shell/useOapInfo.ts b/apps/ui/src/shell/useOapInfo.ts
index cc2ffa1..b23a27a 100644
--- a/apps/ui/src/shell/useOapInfo.ts
+++ b/apps/ui/src/shell/useOapInfo.ts
@@ -19,6 +19,7 @@ import { computed } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import {
   parseOapTimezoneMinutes,
+  type OapBackend,
   type OapCapabilities,
   type OapInfo,
 } from '@skywalking-horizon-ui/api-client';
@@ -88,6 +89,11 @@ export function useOapInfo() {
     queryAlarms: info.value?.capabilities?.queryAlarms ?? false,
   }));
 
+  /** Storage backend probed via TTL response shape. `unknown` until the
+   *  first poll lands, so cold-stage UI affordances stay hidden during
+   *  bootstrap rather than flashing on then disappearing. */
+  const backend = computed<OapBackend>(() => info.value?.backend ?? 'unknown');
+
   /** Health status pill colour: ok / warn / err / unknown. */
   const healthState = computed<'ok' | 'warn' | 'err' | 'unknown'>(() => {
     if (!reachable.value) return 'err';
@@ -107,6 +113,7 @@ export function useOapInfo() {
     healthScore,
     healthState,
     capabilities,
+    backend,
     toServerTzString,
     refetch: q.refetch,
   };
diff --git a/docs/README.md b/docs/README.md
index cd2e4d5..1cf342b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -27,22 +27,7 @@ Horizon runs as a browser UI plus a Horizon server process. The browser talks on
 | Building a "war room" overview | Customization → Overview Templates. |
 | Diagnosing a "module disabled" warning | Compatibility → Required OAP Modules, then Operate → Cluster Status. |
 
-## Live demo
-
-The Apache SkyWalking project runs a public OAP demo at `demo.skywalking.apache.org/graphql` (basic auth `skywalking:skywalking`). Horizon's `horizon.yaml` can point at it for smoke-testing:
-
-```yaml
-oap:
-  queryUrl: https://demo.skywalking.apache.org
-  auth:
-    username: skywalking
-    password: skywalking
-```
-
-The demo exposes the GraphQL query port only; the admin REST port — and therefore the Cluster, Inspect, DSL Management, and Live Debugger pages — is not reachable from outside.
-
 ## References
 
 - [Apache SkyWalking](https://github.com/apache/skywalking) — the backend Horizon UI consumes.
 - [skywalking-booster-ui](https://github.com/apache/skywalking-booster-ui) — the previous-generation UI; Horizon is feature-equivalent against the same OAP protocol.
-- [demo.skywalking.apache.org](https://demo.skywalking.apache.org) — public OAP demo (basic auth `skywalking:skywalking`).
diff --git a/docs/setup/oap.md b/docs/setup/oap.md
index 9fe789e..0498747 100644
--- a/docs/setup/oap.md
+++ b/docs/setup/oap.md
@@ -44,7 +44,7 @@ When `auth.username` and `auth.password` are set:
 - The header is applied identically to `queryUrl`, `adminUrl`, and `zipkinUrl` — there is no per-port credential.
 - In `horizon-wire.jsonl` (when `debugLog.enabled: true`), the header is redacted by default. See [debugLog](debug-log.md).
 
-The public demo at `demo.skywalking.apache.org` uses `skywalking:skywalking`. Production deployments should pull from environment:
+Production deployments should pull credentials from the environment rather than committing them to `horizon.yaml`:
 
 ```yaml
 oap:
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index c49305d..8596d2a 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -99,13 +99,14 @@ export type {
   LogsResponse,
   LogFacetsResponse,
 } from './logs.js';
-export type { OapInfo, OapCapabilities } from './oap-info.js';
+export type { OapInfo, OapCapabilities, OapBackend } from './oap-info.js';
 export type {
   RecordsTTL,
   MetricsTTL,
   OapTtlResponse,
   OapConfigEntry,
   OapConfigResponse,
+  TtlStageBreakdown,
 } from './oap-ops.js';
 export type { PreflightModule, PreflightResult } from './preflight.js';
 export type {
diff --git a/packages/api-client/src/oap-info.ts b/packages/api-client/src/oap-info.ts
index ba58b98..d481d97 100644
--- a/packages/api-client/src/oap-info.ts
+++ b/packages/api-client/src/oap-info.ts
@@ -42,6 +42,10 @@ export interface OapInfo {
    *  Routes / pages branch on this to choose between legacy + new
    *  query shapes (e.g. `getAlarm` vs `queryAlarms`). */
   capabilities?: OapCapabilities;
+  /** Storage backend probed via the TTL response shape (`getRecordsTTL`
+   *  / `getMetricsTTL`). Cached BFF-side for ~5 min — the storage choice
+   *  doesn't change at OAP runtime. Absent when `reachable === false`. */
+  backend?: OapBackend;
   error?: string;
   /** OAP's Zipkin v2 REST base URL (`oap.zipkinUrl`). Only the Zipkin /
    *  OTLP trace menu reads from it — an unreachable Zipkin endpoint does
@@ -63,6 +67,13 @@ export interface OapCapabilities {
   queryAlarms: boolean;
 }
 
+/** Storage backend the connected OAP runs on, as far as the BFF can
+ *  tell from the TTL response shape. `banyandb` ⇒ the UI exposes the
+ *  cold-stage query toggle; `other` ⇒ the toggle is hidden because OAP
+ *  ignores `Duration.coldStage` for non-BanyanDB storage. `unknown` is
+ *  the conservative default until the first probe lands. */
+export type OapBackend = 'banyandb' | 'other' | 'unknown';
+
 /**
  * Convert OAP's `±HHmm` timezone string to signed minutes-from-UTC.
  * Returns `undefined` for malformed input.
diff --git a/packages/api-client/src/oap-ops.ts b/packages/api-client/src/oap-ops.ts
index 108c76f..e1c71e6 100644
--- a/packages/api-client/src/oap-ops.ts
+++ b/packages/api-client/src/oap-ops.ts
@@ -28,7 +28,10 @@
  */
 
 /** OAP `RecordsTTL` — retention (days) for record-class storage.
- *  cold* fields are `-1` when the backend has no cold stage. */
+ *  The plain fields (`normal`/`trace`/…) cover **hot + warm** for BanyanDB
+ *  (already summed by the OAP-side resolver); for non-BanyanDB they're
+ *  just the uniform `coreRecordDataTTL`. `cold*` is the cold-stage
+ *  retention, `-1` when no cold stage is configured. */
 export interface RecordsTTL {
   normal: number;
   trace: number;
@@ -43,12 +46,12 @@ export interface RecordsTTL {
 }
 
 /** OAP `MetricsTTL` — retention (days) for metric-class storage.
- *  `metadata` (TTL of the service/instance/endpoint/topology inventory)
- *  is not exposed by every OAP deployment, so it stays optional; the
- *  TTL query does not request it (matches booster) and the UI renders
- *  the row only when present. `cold*` is the cold-stage retention, `-1`
- *  when no cold stage is configured. Do NOT infer the storage backend
- *  from these fields — read the wire values verbatim and display them. */
+ *  Same hot+warm vs cold semantics as {@link RecordsTTL}. `metadata`
+ *  (service/instance/endpoint/topology inventory) is not exposed by
+ *  every deployment, so it stays optional; the row is rendered only
+ *  when present. Do NOT infer the storage backend directly from these
+ *  fields — the BFF computes {@link OapTtlResponse.backend} once and
+ *  the UI reads that. */
 export interface MetricsTTL {
   metadata?: number;
   minute: number;
@@ -59,14 +62,53 @@ export interface MetricsTTL {
   coldDay: number;
 }
 
+/** Storage backend the connected OAP runs on, as far as the TTL probe
+ *  can tell. `banyandb` enables the cold-stage UI affordances (the
+ *  topbar Cold pill, the TTL page's cold pane); `other` hides them.
+ *  `unknown` is the conservative result when OAP is unreachable or the
+ *  probe hasn't landed yet. */
+export type OapBackend = 'banyandb' | 'other' | 'unknown';
+
+/** One stage's per-class retention in days. Mirrors {@link RecordsTTL}
+ *  / {@link MetricsTTL} but flattened into a single "stage" so the UI
+ *  can render hot+warm and cold with one component shape. */
+export interface TtlStageBreakdown {
+  records: {
+    normal: number;
+    trace: number;
+    zipkinTrace: number;
+    log: number;
+    browserErrorLog: number;
+  };
+  metrics: {
+    metadata?: number;
+    minute: number;
+    hour: number;
+    day: number;
+  };
+}
+
 /** Wire shape of `GET /api/oap/ttl`. Never throws on an unreachable OAP
  *  — `reachable: false` + `error` carries the diagnostic so the page can
- *  render a degraded state instead of a hard failure. */
+ *  render a degraded state instead of a hard failure.
+ *
+ *  `backend` and `stages` are derived from `records`/`metrics` by the
+ *  BFF; both are present when `reachable === true`. `stages.cold` is
+ *  `null` when no `cold*` field is configured (covers non-BanyanDB
+ *  always, and BanyanDB without a cold lifecycle stage). */
 export interface OapTtlResponse {
   reachable: boolean;
   error?: string;
   records?: RecordsTTL;
   metrics?: MetricsTTL;
+  backend?: OapBackend;
+  stages?: {
+    /** Hot + warm — queried by default. */
+    hot: TtlStageBreakdown;
+    /** Cold — opt-in per query via `Duration.coldStage: true`. `null`
+     *  when no cold stage is configured for any class. */
+    cold: TtlStageBreakdown | null;
+  };
 }
 
 /** A single resolved config key→value from `/debugging/config/dump`. */