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`. */