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/06/21 01:00:43 UTC

(skywalking-horizon-ui) 01/01: fix: RC0 Tier-1 correctness + Tier-2 hygiene

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

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

commit 6f16fffba3126ef685c6dceed0aeb6f484d7a407
Author: Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Jun 21 09:00:32 2026 +0800

    fix: RC0 Tier-1 correctness + Tier-2 hygiene
    
    Tier-1 (correctness):
    - endpoint-dependency: honor the topbar time picker end-to-end (BFF route + client + composable + in-view expand). It was pinned to the last hour; it now queries the selected window like the service/instance maps. Stale "last 15 minutes" empty-state copy fixed.
    - dashboard: degrade per metric chunk — a transient failure on one widget batch marks only those widgets instead of blanking the whole dashboard (keeps reachable:true).
    - window: key the server-timezone cache on the OAP query URL, so a config reload that repoints OAP re-probes immediately instead of serving the previous server's offset for up to the TTL.
    - trace (v2 / BanyanDB): select the root span by empty refs, not just parentSpanId === -1, so a multi-segment trace's list row reflects the true entry span; guard the empty endpoint name.
    - live-debug: gate /api/debug/status on live-debug:read alone (drop the redundant in-handler cluster:read double-gate).
    
    Tier-2 (hygiene):
    - Remove the ?mockTop= synthetic-row injector from the dashboard route.
    - Remove two leftover console.log statements from the topology request path.
    - Add baseline security response headers (nosniff / DENY / no-referrer) via an onSend hook.
    - Dead code: drop the unused invalidateServerOffsetCache + defaultSecondWindow, the UTC-formatting chain behind the no-consumer timeRange `duration` export, and the unused requireVerb pre-handler.
    
    i18n: one swapped UI string translated across all 8 locales. CHANGELOG updated under 0.7.0.
---
 CHANGELOG.md                                       |   7 ++
 apps/bff/src/http/admin/live-debug.ts              |   5 +-
 apps/bff/src/http/query/dashboard.ts               | 102 ++++++++-------------
 apps/bff/src/http/query/endpoint-dependency.ts     |  39 ++++++--
 apps/bff/src/http/query/topology.ts                |   4 -
 apps/bff/src/http/query/trace.ts                   |   7 +-
 apps/bff/src/server.ts                             |  10 ++
 apps/bff/src/user/middleware.ts                    |  38 ++------
 apps/bff/src/util/window.ts                        |  34 ++-----
 apps/ui/src/api/scopes/layer.ts                    |   6 ++
 apps/ui/src/controls/timeRange.ts                  |  33 -------
 apps/ui/src/i18n/locales/de.json                   |   2 +-
 apps/ui/src/i18n/locales/en.json                   |   2 +-
 apps/ui/src/i18n/locales/es.json                   |   2 +-
 apps/ui/src/i18n/locales/fr.json                   |   2 +-
 apps/ui/src/i18n/locales/ja.json                   |   2 +-
 apps/ui/src/i18n/locales/ko.json                   |   2 +-
 apps/ui/src/i18n/locales/pt.json                   |   2 +-
 apps/ui/src/i18n/locales/zh-CN.json                |   2 +-
 .../LayerEndpointDependencyView.vue                |   5 +-
 .../useLayerEndpointDependency.ts                  |  10 +-
 21 files changed, 136 insertions(+), 180 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f95b75b..4366682 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -125,11 +125,18 @@ The version line is shared by every package in the monorepo (apps + shared packa
 
 - The **Roles & Permissions** board now lists `infra-3d:read` — the permission to view the **3D Infrastructure Map** — under the data-catalog group, with a matching "3D infrastructure map" row in the menu-visibility matrix. It was already enforced and granted to every built-in role (viewer and up), but it never appeared on the board, so an admin couldn't see who held it.
 - Editing a **layer dashboard template** is now gated on the `dashboard:write` permission the editor already advertises; publishing overview, alert, and 3D-map configs stays on `overview:write`. The required permission is resolved per template kind at save time. Built-in roles are unaffected (`operator` and `admin` hold both), but a custom role granted only `dashboard:write` can now save layer dashboards.
+- The **Cluster Status debug view** (`/api/debug/status`) now requires only `live-debug:read`. It previously also demanded `cluster:read`, so a role granted live-debug access but not cluster-read was wrongly blocked.
 
 ### Fixes
 
 - **Metrics Inspect** — the crosshair value tooltip is no longer clipped behind the navigation sidebar when you hover near a widget's left edge; it now renders above the page chrome.
 - **3D Infrastructure Map config** — "Check diff & push" now requires saving a local draft first (it was selectable while edits were still unsaved), and the push dialog renders the side-by-side before/after JSON diff instead of an empty panel.
+- **The API-dependency tab now honors the topbar time picker.** It was pinned to the last hour regardless of the selected range; changing the range (and expanding a node) now re-queries the chosen window, like the service map and instance map already did.
+- **A dashboard no longer blanks entirely when one metric group fails.** A transient backend error (timeout / 5xx / query-complexity limit) on a single batch of widgets now marks only those widgets as failed; the rest of the dashboard renders normally instead of every cell going blank.
+- **Trace list rows pick the correct root span on BanyanDB.** A multi-service trace could surface a downstream span's endpoint / duration / start time in the list; the row now reliably reflects the trace's true entry span.
+- **Correct timestamps right after repointing OAP.** The server-timezone offset is now cached per OAP URL, so a configuration reload that switches to a different-timezone OAP re-probes immediately instead of serving the previous server's offset for up to a minute.
+- **Baseline security response headers** (`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`) are now sent on every response.
+- Removed an internal `?mockTop=` debug query parameter that padded top-N widgets with synthetic rows; it no longer ships in release builds.
 
 ### Documentation & release tooling
 
diff --git a/apps/bff/src/http/admin/live-debug.ts b/apps/bff/src/http/admin/live-debug.ts
index b593784..0b87dbf 100644
--- a/apps/bff/src/http/admin/live-debug.ts
+++ b/apps/bff/src/http/admin/live-debug.ts
@@ -207,8 +207,9 @@ export function registerDebugRoutes(app: FastifyInstance, deps: DebugRouteDeps):
   app.get(
     '/api/debug/status',
     { preHandler: auth },
-    async (req: FastifyRequest, reply: FastifyReply) => {
-      if (!ensureVerb(req, reply, deps, 'cluster:read')) return;
+    async (_req: FastifyRequest, reply: FastifyReply) => {
+      // Gated by `live-debug:read` in ROUTE_POLICY (the single source of truth);
+      // no extra in-handler verb so a caller doesn't need two grants.
       const c = clients();
       const targets = await resolveTargets(c.adminUrl());
       const nodes = await Promise.all(
diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts
index 534af9c..72c21ff 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -555,11 +555,6 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
       if (!parsed.success) {
         return reply.code(400).send({ error: 'invalid_body', detail: parsed.error.flatten() });
       }
-      // Dev-mode query param `?mockTop=N` pads every TopList result to
-      // exactly N entries with synthetic rows. Use to verify widget
-      // height / overflow without waiting for OAP to populate the layer.
-      const mockTopRaw = (req.query as { mockTop?: string }).mockTop;
-      const mockTopN = mockTopRaw ? Math.max(0, Math.min(40, Number(mockTopRaw))) : 0;
       const scope = parsed.data.scope ?? 'service';
       const eff = await resolveEffectiveLayer(deps.uiTemplateClient, layerKey);
       // Blocked (template store unreachable / layer disabled) → no
@@ -800,40 +795,40 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
         widgetChunks.push(batchWidgets.slice(i, i + MAX_WIDGETS_PER_BATCH));
       }
       const data: Record<string, MqeResultShape> = {};
-      try {
-        const chunkResults = await Promise.all(
-          widgetChunks.map(async (chunk) => {
-            const fragments: string[] = [];
-            for (const { widget, wIdx } of chunk) {
-              widget.expressions.forEach((expr, eIdx) => {
-                const alias = `w${wIdx}_e${eIdx}`;
-                fragments.push(
-                  buildFragment(alias, expr, serviceName, normal, window, {
-                    layerScope: widget.layerScope === true,
-                    serviceInstanceName:
-                      widget.layerScope !== true && scopeHonorsInstance ? selectedInstance : null,
-                    endpointName:
-                      widget.layerScope !== true && scopeHonorsEndpoint ? selectedEndpoint : null,
-                    coldStage: !!req.coldStage,
-                  }),
-                );
-              });
-            }
-            if (fragments.length === 0) return {} as Record<string, MqeResultShape>;
-            const query = `query DashboardMqe { ${fragments.join('\n    ')} }`;
-            return graphqlPost<Record<string, MqeResultShape>>(opts, query);
-          }),
-        );
-        for (const chunk of chunkResults) {
-          Object.assign(data, chunk);
-        }
-      } catch (err) {
-        return reply.send({
-          ...baseResp,
-          reachable: false,
-          error: err instanceof Error ? err.message : String(err),
-          widgets: widgets.map((w) => ({ id: w.id, error: 'mqe batch failed' })),
-        });
+      // One chunk failing (transient 5xx / timeout / OAP complexity-limit) must
+      // not blank the whole dashboard — catch per chunk, mark only that chunk's
+      // widgets, and let the rest render their own no-data/value state.
+      const failedWidgetIdx = new Set<number>();
+      const chunkResults = await Promise.all(
+        widgetChunks.map(async (chunk) => {
+          const fragments: string[] = [];
+          for (const { widget, wIdx } of chunk) {
+            widget.expressions.forEach((expr, eIdx) => {
+              const alias = `w${wIdx}_e${eIdx}`;
+              fragments.push(
+                buildFragment(alias, expr, serviceName, normal, window, {
+                  layerScope: widget.layerScope === true,
+                  serviceInstanceName:
+                    widget.layerScope !== true && scopeHonorsInstance ? selectedInstance : null,
+                  endpointName:
+                    widget.layerScope !== true && scopeHonorsEndpoint ? selectedEndpoint : null,
+                  coldStage: !!req.coldStage,
+                }),
+              );
+            });
+          }
+          if (fragments.length === 0) return {} as Record<string, MqeResultShape>;
+          const query = `query DashboardMqe { ${fragments.join('\n    ')} }`;
+          try {
+            return await graphqlPost<Record<string, MqeResultShape>>(opts, query);
+          } catch {
+            for (const { wIdx } of chunk) failedWidgetIdx.add(wIdx);
+            return {} as Record<string, MqeResultShape>;
+          }
+        }),
+      );
+      for (const chunk of chunkResults) {
+        Object.assign(data, chunk);
       }
 
       // Step 3 — collapse per widget. Per-type handling:
@@ -851,33 +846,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
             items: NonNullable<ReturnType<typeof parseTopList>>;
           }> = [];
           widget.expressions.forEach((expr, eIdx) => {
-            let items = parseTopList(data[`w${wIdx}_e${eIdx}`]);
-            // Pad with synthetic rows when mockTop is requested. Each
-            // padded row gets a plausible name + a value tapered down
-            // from the last real row so the list reads as ranked.
-            if (mockTopN > 0) {
-              const current = items ?? [];
-              const padCount = Math.max(0, mockTopN - current.length);
-              if (padCount > 0) {
-                const last = current[current.length - 1]?.value ?? 100;
-                const seed = current[current.length - 1]?.name ?? 'mock-service';
-                const padded = Array.from({ length: padCount }, (_, i) => {
-                  const idx = current.length + i + 1;
-                  const decay = 1 - (i + 1) / (padCount + 2);
-                  return {
-                    name: `${seed} · mock-${idx}`,
-                    value: typeof last === 'number' ? Math.round(last * decay * 100) / 100 : 0,
-                  };
-                });
-                items = [...current, ...padded];
-              }
-              if (!items || items.length === 0) {
-                items = Array.from({ length: mockTopN }, (_, i) => ({
-                  name: `mock-entity-${i + 1}`,
-                  value: Math.round((100 - i * 8) * 100) / 100,
-                }));
-              }
-            }
+            const items = parseTopList(data[`w${wIdx}_e${eIdx}`]);
             if (!items) return;
             const label = widget.expressionLabels?.[eIdx] ?? expr;
             const unit = widget.expressionUnits?.[eIdx];
@@ -970,6 +939,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
       const results: DashboardWidgetResult[] = widgets.map((widget, wIdx) => {
         // Group/entity gate already decided this one out (no MQE ran).
         if (skipped.has(wIdx)) return { id: widget.id, hidden: true };
+        if (failedWidgetIdx.has(wIdx)) return { id: widget.id, error: 'mqe batch failed' };
         const result = collapse(widget, wIdx);
         // SELF gate — evaluate the predicate against the widget's own
         // gate expression result (exact: only that expression's values,
diff --git a/apps/bff/src/http/query/endpoint-dependency.ts b/apps/bff/src/http/query/endpoint-dependency.ts
index bd0f6b2..67bb0e1 100644
--- a/apps/bff/src/http/query/endpoint-dependency.ts
+++ b/apps/bff/src/http/query/endpoint-dependency.ts
@@ -43,7 +43,12 @@ import type {
 import { requireAuth } from '../../user/middleware.js';
 import {  graphqlPost, buildOapOpts } from '../../client/graphql.js';
 import { withColdStage } from '../../util/duration.js';
-import { defaultMinuteWindow, getServerOffsetMinutes } from '../../util/window.js';
+import {
+  defaultMinuteWindow,
+  getServerOffsetMinutes,
+  windowFromRange,
+  type TimeStep,
+} from '../../util/window.js';
 import { endpointDependencyConfigFor } from '../../logic/layers/loader.js';
 import { resolveEffectiveLayer } from '../../logic/layers/effective.js';
 import { parsePreviewEndpointDep } from '../../logic/layers/preview.js';
@@ -120,7 +125,7 @@ function endpointFragment(
   serviceName: string,
   endpointName: string,
   normal: boolean,
-  w: { start: string; end: string },
+  w: { start: string; end: string; step: TimeStep },
   coldStage: boolean,
 ): string {
   const coldFrag = coldStage ? ', coldStage: true' : '';
@@ -131,7 +136,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${coldFrag} }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -150,7 +155,7 @@ function endpointRelationFragment(
   destServiceName: string,
   destEndpointName: string,
   destNormal: boolean,
-  w: { start: string; end: string },
+  w: { start: string; end: string; step: TimeStep },
   coldStage: boolean,
 ): string {
   const coldFrag = coldStage ? ', coldStage: true' : '';
@@ -164,7 +169,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${coldFrag} }\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: ${JSON.stringify(w.end)}, step: ${w.step}${coldFrag} }\n` +
     `    ) { type error results { values { value } } }`
   );
 }
@@ -253,7 +258,14 @@ export function registerEndpointDependencyRoute(
       if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
         return reply.code(400).send({ error: 'invalid_layer_key' });
       }
-      const q = req.query as { service?: string; endpoint?: string; previewConfig?: string };
+      const q = req.query as {
+        service?: string;
+        endpoint?: string;
+        previewConfig?: string;
+        step?: string;
+        startMs?: string;
+        endMs?: string;
+      };
       const serviceArg = (q.service ?? '').trim();
       const endpointArg = (q.endpoint ?? '').trim();
       if (!serviceArg) return reply.code(400).send({ error: 'missing_service' });
@@ -277,9 +289,20 @@ export function registerEndpointDependencyRoute(
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
       const offset = await getServerOffsetMinutes(deps.config, deps.fetch);
-      const window = defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
+      // Honor the SPA's topbar picker triplet; else fall back to the
+      // last-hour MINUTE window (dashboards family — minute precision).
+      const stepArg = (q.step ?? '').toUpperCase() as TimeStep;
+      const startMs = Number(q.startMs);
+      const endMs = Number(q.endMs);
+      const window =
+        (stepArg === 'MINUTE' || stepArg === 'HOUR' || stepArg === 'DAY') &&
+        Number.isFinite(startMs) &&
+        Number.isFinite(endMs)
+          ? windowFromRange(stepArg, startMs, endMs, offset) ??
+            defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN)
+          : defaultMinuteWindow(offset, DEFAULT_WINDOW_MIN);
       const oapLayer = layerKey.toUpperCase();
-      const durationVar = withColdStage(req, { 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 service name → id.
diff --git a/apps/bff/src/http/query/topology.ts b/apps/bff/src/http/query/topology.ts
index cbe4d32..37f7dfe 100644
--- a/apps/bff/src/http/query/topology.ts
+++ b/apps/bff/src/http/query/topology.ts
@@ -365,9 +365,6 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
           seedIds = data.services
             .filter((s) => group === undefined || ((s as { group?: string }).group ?? '') === group)
             .map((s) => s.id);
-          // Debug log so the response size is visible while we
-          // diagnose why layers with many services come back small.
-          console.log(`[topology] layer=${oapLayer} seed-services=${seedIds.length}`);
         }
       } catch (err) {
         return reply.send(
@@ -425,7 +422,6 @@ export function registerTopologyRoute(app: FastifyInstance, deps: TopologyRouteD
       // don't belong on a topology map. The earlier "fill them in as
       // standalone nodes" pass was reverted after a closer look at
       // booster-ui's demo, which only renders connected nodes too.)
-      console.log(`[topology] layer=${oapLayer} returned-nodes=${nodes.size} edges=${calls.size}`);
 
       // ── Per-node MQE. Builds fragments off the layer's
       // `topology.nodeMetrics`. Synthetic nodes (User / external) are
diff --git a/apps/bff/src/http/query/trace.ts b/apps/bff/src/http/query/trace.ts
index 3dc134c..9f03e0a 100644
--- a/apps/bff/src/http/query/trace.ts
+++ b/apps/bff/src/http/query/trace.ts
@@ -329,12 +329,15 @@ async function fetchNativeList(
         data: { traces: Array<{ spans: NativeSpan[] }> };
       }>(opts, QUERY_TRACES, { condition });
       const traces = (env.data?.traces ?? []).map((t) => {
-        const root = t.spans.find((s) => s.parentSpanId === -1) ?? t.spans[0];
+        // v2 spans are flat across all segments; every segment's entry span
+        // has parentSpanId === -1, so match the global root by its empty refs
+        // (booster-ui does the same) — else a downstream callee can win.
+        const root = t.spans.find((s) => s.parentSpanId === -1 && s.refs.length === 0) ?? t.spans[0];
         const ids = Array.from(new Set(t.spans.map((s) => s.traceId)));
         return {
           key: root?.segmentId ?? ids[0] ?? '',
           segmentId: root?.segmentId ?? '',
-          endpointNames: root ? [root.endpointName] : [],
+          endpointNames: root?.endpointName ? [root.endpointName] : [],
           duration: root ? root.endTime - root.startTime : 0,
           start: root ? String(root.startTime) : '',
           isError: t.spans.some((s) => s.isError),
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index 0e42096..29f937f 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -128,6 +128,16 @@ app.setErrorHandler((err, _req, reply) => {
   return reply.status(500).send({ code: 'internal_error', message });
 });
 
+// Baseline security headers on every response (MIME-sniff / clickjacking /
+// referrer leakage). Defense-in-depth for the console behind the operator's
+// ingress; no third-party dependency.
+app.addHook('onSend', (_req, reply, payload, done) => {
+  reply.header('X-Content-Type-Options', 'nosniff');
+  reply.header('X-Frame-Options', 'DENY');
+  reply.header('Referrer-Policy', 'no-referrer');
+  done(null, payload);
+});
+
 const sessions = new SessionStore({ ttlMinutes: source.current.session.ttlMinutes });
 const audit = new AuditLogger(source.current.audit.file);
 await audit.open();
diff --git a/apps/bff/src/user/middleware.ts b/apps/bff/src/user/middleware.ts
index 9a7d616..fa5a32c 100644
--- a/apps/bff/src/user/middleware.ts
+++ b/apps/bff/src/user/middleware.ts
@@ -16,25 +16,18 @@
  */
 
 /**
- * Per-request auth + RBAC pre-handlers.
+ * Per-request authentication pre-handler. Verb gating is applied separately
+ * by the ROUTE_POLICY `onRoute` hook (rbac/route-policy.ts); routes wire only
+ * `requireAuth` here.
  *
- * Typical wiring at the route-registration site:
- *
- *   app.get(
- *     '/api/alarms',
- *     { preHandler: [requireAuth(deps), requireVerb(deps, 'alarms:read')] },
- *     handler,
- *   );
- *
- * Both pre-handlers send a JSON error and short-circuit; nothing else
- * runs once they reply. Errors use `reply.code(...).send(...)` rather
- * than `throw` because Fastify's global error handler swallows the
- * `WWW-Authenticate`-style metadata we may want to attach in the future.
+ * It sends a JSON 401 and short-circuits when there's no valid session, using
+ * `reply.code(...).send(...)` rather than `throw` because Fastify's global
+ * error handler swallows the `WWW-Authenticate`-style metadata we may want to
+ * attach in the future.
  */
 
 import type { FastifyReply, FastifyRequest } from 'fastify';
 import type { ConfigSource } from '../config/loader.js';
-import { sessionHasVerb } from '../rbac/policy.js';
 import type { Session, SessionStore } from './sessions.js';
 
 declare module 'fastify' {
@@ -76,20 +69,3 @@ export function requireAuth(deps: AuthDeps) {
     });
   };
 }
-
-export function requireVerb(deps: AuthDeps, verb: string) {
-  const auth = requireAuth(deps);
-  return async function verbPreHandler(req: FastifyRequest, reply: FastifyReply): Promise<void> {
-    await auth(req, reply);
-    // `requireAuth` may have already sent a 401; in that case `reply.sent`
-    // is true and we must not write again.
-    if (reply.sent) return;
-    const session = req.session;
-    if (!session) {
-      return void reply.code(401).send({ error: 'unauthenticated' });
-    }
-    if (!sessionHasVerb(deps.config.current, session.roles, verb)) {
-      return void reply.code(403).send({ error: 'permission_denied', verb });
-    }
-  };
-}
diff --git a/apps/bff/src/util/window.ts b/apps/bff/src/util/window.ts
index f2a3127..dfd9ae1 100644
--- a/apps/bff/src/util/window.ts
+++ b/apps/bff/src/util/window.ts
@@ -109,21 +109,6 @@ export function defaultMinuteWindow(
   };
 }
 
-/** Last-N-minutes SECOND window in OAP-local time. Used by record routes
- *  (traces, logs) when no explicit range is supplied. */
-export function defaultSecondWindow(
-  offsetMinutes: number,
-  minutesBack = 30,
-): { start: string; end: string; step: 'SECOND' } {
-  const endMs = Date.now();
-  const startMs = endMs - minutesBack * 60_000;
-  return {
-    start: fmtSecond(startMs, offsetMinutes),
-    end: fmtSecond(endMs, offsetMinutes),
-    step: 'SECOND',
-  };
-}
-
 /** Build an OAP {@link Window} from an operator-supplied range. All three
  *  inputs must be present and `endMs > startMs`; returns null otherwise so
  *  the caller can fall back to a default window. Generic on the step so
@@ -148,6 +133,10 @@ export function windowFromRange<S extends TimeStep>(
 interface TzCacheEntry {
   offsetMinutes: number;
   fetchedAt: number;
+  /** OAP query URL this offset was probed from — a config hot-reload that
+   *  repoints OAP misses the cache and re-probes, instead of serving a
+   *  different server's offset for up to the TTL. */
+  queryUrl: string;
 }
 let tzCache: TzCacheEntry | null = null;
 const TZ_TTL_MS = 60_000;
@@ -160,12 +149,6 @@ const TIME_INFO_QUERY = /* GraphQL */ `
   }
 `;
 
-/** Reset the cached offset. Tests + the `info` route call this on
- *  reconfigure / health-check so a new OAP target re-probes. */
-export function invalidateServerOffsetCache(): void {
-  tzCache = null;
-}
-
 /** Look up the OAP server's UTC offset in minutes. Cached 60s.
  *
  *  Returns 0 (UTC fallback) if the probe fails — same behavior as
@@ -178,7 +161,10 @@ export async function getServerOffsetMinutes(
   fetchImpl?: FetchLike,
 ): Promise<number> {
   const now = Date.now();
-  if (tzCache && now - tzCache.fetchedAt < TZ_TTL_MS) return tzCache.offsetMinutes;
+  const queryUrl = config.current.oap.queryUrl;
+  if (tzCache && tzCache.queryUrl === queryUrl && now - tzCache.fetchedAt < TZ_TTL_MS) {
+    return tzCache.offsetMinutes;
+  }
   try {
     const env = await graphqlPost<{ time?: { timezone?: string | null } | null }>(
       buildOapOpts(config.current, fetchImpl),
@@ -191,12 +177,12 @@ export async function getServerOffsetMinutes(
       const h = parseInt(m[2], 10);
       const mi = parseInt(m[3], 10);
       const offset = sign * (h * 60 + mi);
-      tzCache = { offsetMinutes: offset, fetchedAt: now };
+      tzCache = { offsetMinutes: offset, fetchedAt: now, queryUrl };
       return offset;
     }
   } catch {
     /* fall through to 0 */
   }
-  tzCache = { offsetMinutes: 0, fetchedAt: now };
+  tzCache = { offsetMinutes: 0, fetchedAt: now, queryUrl };
   return 0;
 }
diff --git a/apps/ui/src/api/scopes/layer.ts b/apps/ui/src/api/scopes/layer.ts
index 6532aae..b7ad00b 100644
--- a/apps/ui/src/api/scopes/layer.ts
+++ b/apps/ui/src/api/scopes/layer.ts
@@ -261,10 +261,16 @@ export class LayerApi {
     layerKey: string,
     service: string,
     endpoint: string,
+    range?: { step: 'MINUTE' | 'HOUR' | 'DAY'; startMs: number; endMs: number },
     /** Admin preview: the operator's draft `endpointDependency` block. */
     previewConfig?: string,
   ): Promise<EndpointDependencyResponse> {
     const qs = new URLSearchParams({ service, endpoint });
+    if (range) {
+      qs.set('step', range.step);
+      qs.set('startMs', String(range.startMs));
+      qs.set('endMs', String(range.endMs));
+    }
     if (previewConfig) qs.set('previewConfig', previewConfig);
     return this.bff.request(
       'GET',
diff --git a/apps/ui/src/controls/timeRange.ts b/apps/ui/src/controls/timeRange.ts
index e6e5b81..58663c8 100644
--- a/apps/ui/src/controls/timeRange.ts
+++ b/apps/ui/src/controls/timeRange.ts
@@ -90,28 +90,6 @@ export function isValidRange(step: TimeStep, durationMs: number): boolean {
   return durationMs >= lim.minMs && durationMs <= lim.maxMs;
 }
 
-/** Format `Date` → `YYYY-MM-DD HHmm` (OAP's MINUTE-step format). */
-function fmtMinute(d: Date): string {
-  const z = (n: number) => String(n).padStart(2, '0');
-  return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 1)}-${z(d.getUTCDate())} ${z(d.getUTCHours())}${z(d.getUTCMinutes())}`;
-}
-/** Format `Date` → `YYYY-MM-DD HH` (OAP HOUR-step). */
-function fmtHour(d: Date): string {
-  const z = (n: number) => String(n).padStart(2, '0');
-  return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 1)}-${z(d.getUTCDate())} ${z(d.getUTCHours())}`;
-}
-/** Format `Date` → `YYYY-MM-DD` (OAP DAY-step). */
-function fmtDay(d: Date): string {
-  const z = (n: number) => String(n).padStart(2, '0');
-  return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 1)}-${z(d.getUTCDate())}`;
-}
-/** Pick the right formatter for an OAP `step`. */
-export function fmtForStep(step: TimeStep, d: Date): string {
-  if (step === 'DAY') return fmtDay(d);
-  if (step === 'HOUR') return fmtHour(d);
-  return fmtMinute(d);
-}
-
 export const useTimeRangeStore = defineStore('time-range', () => {
   /** Selected preset id (`'1h'`, `'7d'`, …) or `'custom'` for an
    *  explicit start/end range. */
@@ -140,16 +118,6 @@ export const useTimeRangeStore = defineStore('time-range', () => {
     return { startMs: customStartMs.value, endMs: customEndMs.value };
   });
 
-  /** OAP `Duration` object for the current range. */
-  const duration = computed<{ start: string; end: string; step: TimeStep }>(() => {
-    const r = range.value;
-    return {
-      start: fmtForStep(step.value, new Date(r.startMs)),
-      end: fmtForStep(step.value, new Date(r.endMs)),
-      step: step.value,
-    };
-  });
-
   const label = computed<string>(() => {
     if (preset.value) return preset.value.label;
     return 'Custom range';
@@ -194,7 +162,6 @@ export const useTimeRangeStore = defineStore('time-range', () => {
     step,
     durationMs,
     range,
-    duration,
     label,
     selectPreset,
     selectCustom,
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 8397df8..3f18c32 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "Kantenmetriken (serverseitig)",
   "Loading callers and callees of {name}…": "Aufrufer und Aufgerufene von {name} werden geladen…",
   "L{i} · Callers": "L{i} · Aufrufer",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "Für diesen Endpoint sind in den letzten 15 Minuten keine Abhängigkeitsdaten verfügbar.",
+  "No dependency graph available for this endpoint in the selected time range.": "Für diesen Endpoint sind im ausgewählten Zeitraum keine Abhängigkeitsdaten verfügbar.",
   "No endpoints found.": "Keine Endpoints gefunden.",
   "No further callers or callees for {name}": "Keine weiteren Aufrufer oder Aufgerufene für {name}",
   "OAP unreachable.": "OAP nicht erreichbar.",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index e4b5e8f..112ca4b 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -382,7 +382,7 @@
   "Line metrics (server-side)": "Line metrics (server-side)",
   "Loading callers and callees of {name}…": "Loading callers and callees of {name}…",
   "L{i} · Callers": "L{i} · Callers",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "No dependency graph available for this endpoint in the last 15 minutes.",
+  "No dependency graph available for this endpoint in the selected time range.": "No dependency graph available for this endpoint in the selected time range.",
   "No endpoints found.": "No endpoints found.",
   "No further callers or callees for {name}": "No further callers or callees for {name}",
   "OAP unreachable.": "OAP unreachable.",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 6cca984..2355f61 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "Métricas de línea (lado servidor)",
   "Loading callers and callees of {name}…": "Cargando emisores y destinos de {name}…",
   "L{i} · Callers": "L{i} · Emisores",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "No hay grafo de dependencias para este endpoint en los últimos 15 minutos.",
+  "No dependency graph available for this endpoint in the selected time range.": "No hay grafo de dependencias para este endpoint en el intervalo de tiempo seleccionado.",
   "No endpoints found.": "No se encontraron endpoints.",
   "No further callers or callees for {name}": "No hay más emisores ni destinos para {name}",
   "OAP unreachable.": "OAP inaccesible.",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index c41443f..ec3e344 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "Métriques du lien (côté serveur)",
   "Loading callers and callees of {name}…": "Chargement des appelants et appelés de {name}…",
   "L{i} · Callers": "L{i} · Appelants",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "Aucun graphe de dépendances disponible pour cet endpoint sur les 15 dernières minutes.",
+  "No dependency graph available for this endpoint in the selected time range.": "Aucun graphe de dépendances disponible pour cet endpoint sur la plage de temps sélectionnée.",
   "No endpoints found.": "Aucun endpoint trouvé.",
   "No further callers or callees for {name}": "Aucun autre appelant ou appelé pour {name}",
   "OAP unreachable.": "OAP injoignable.",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 1667004..cab0eb9 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "ラインメトリクス (サーバー側)",
   "Loading callers and callees of {name}…": "{name} の呼び出し元と呼び出し先を読み込み中…",
   "L{i} · Callers": "L{i} · 呼び出し元",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "このエンドポイントには直近 15 分間の依存関係グラフがありません。",
+  "No dependency graph available for this endpoint in the selected time range.": "このエンドポイントには選択した時間範囲の依存関係グラフがありません。",
   "No endpoints found.": "エンドポイントが見つかりません。",
   "No further callers or callees for {name}": "{name} にこれ以上の呼び出し元・呼び出し先はありません",
   "OAP unreachable.": "OAP に接続できません。",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 9b29aee..9219789 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "선 메트릭 (서버 측)",
   "Loading callers and callees of {name}…": "{name}의 호출자와 피호출 불러오는 중…",
   "L{i} · Callers": "L{i} · 호출자",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "최근 15분 동안 이 엔드포인트의 의존성 그래프가 없습니다.",
+  "No dependency graph available for this endpoint in the selected time range.": "선택한 시간 범위 동안 이 엔드포인트의 의존성 그래프가 없습니다.",
   "No endpoints found.": "엔드포인트를 찾을 수 없습니다.",
   "No further callers or callees for {name}": "{name}의 추가 호출자나 피호출이 없습니다",
   "OAP unreachable.": "OAP에 연결할 수 없습니다.",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 3856c61..0a50561 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "Métricas de linha (lado do servidor)",
   "Loading callers and callees of {name}…": "Carregando chamadores e chamados de {name}…",
   "L{i} · Callers": "L{i} · Chamadores",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "Nenhum grafo de dependências disponível para este endpoint nos últimos 15 minutos.",
+  "No dependency graph available for this endpoint in the selected time range.": "Nenhum grafo de dependências disponível para este endpoint no intervalo de tempo selecionado.",
   "No endpoints found.": "Nenhum endpoint encontrado.",
   "No further callers or callees for {name}": "Sem mais chamadores ou chamados para {name}",
   "OAP unreachable.": "OAP inacessível.",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 2dfe257..8ed4bee 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -378,7 +378,7 @@
   "Line metrics (server-side)": "连线指标(服务端)",
   "Loading callers and callees of {name}…": "正在加载 {name} 的调用方与被调方…",
   "L{i} · Callers": "L{i} · 调用方",
-  "No dependency graph available for this endpoint in the last 15 minutes.": "最近 15 分钟内该端点暂无可用的依赖图。",
+  "No dependency graph available for this endpoint in the selected time range.": "所选时间范围内该端点暂无可用的依赖图。",
   "No endpoints found.": "未找到端点。",
   "No further callers or callees for {name}": "{name} 没有更多的调用方或被调方",
   "OAP unreachable.": "无法连接 OAP。",
diff --git a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
index 4892aea..aef07a2 100644
--- a/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
+++ b/apps/ui/src/layer/endpoint-dependency/LayerEndpointDependencyView.vue
@@ -43,6 +43,7 @@ import type {
 } from '@/api/client';
 import { bffClient } from '@/api/client';
 import { useLayerEndpointDependency } from '@/layer/endpoint-dependency/useLayerEndpointDependency';
+import { useTimeRangeStore } from '@/controls/timeRange';
 import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
 import { useLayerLanding } from '@/layer/useLayerLanding';
 import { useLayers } from '@/shell/useLayers';
@@ -58,6 +59,7 @@ import Sparkline from '@/components/charts/Sparkline.vue';
 const route = useRoute();
 const router = useRouter();
 const { t } = useI18n({ useScope: 'global' });
+const timeRange = useTimeRangeStore();
 const layerKey = computed(() => String(route.params.layerKey ?? ''));
 
 const { selectedId, setSelected: setSelectedService } = useSelectedService();
@@ -187,6 +189,7 @@ async function expandNode(node: EndpointDependencyNode): Promise<void> {
       layerKey.value,
       node.serviceName,
       node.name,
+      { step: timeRange.step, startMs: timeRange.range.startMs, endMs: timeRange.range.endMs },
     );
     const next = new Map(expansions.value);
     next.set(key, resp);
@@ -1256,7 +1259,7 @@ function edgeRowCrosshair(rowId: string): number | null {
 
           <div v-else-if="isLoading" class="loader">{{ t('loading…') }}</div>
           <div v-else class="loader">
-            {{ t('No dependency graph available for this endpoint in the last 15 minutes.') }}
+            {{ t('No dependency graph available for this endpoint in the selected time range.') }}
           </div>
         </div>
 
diff --git a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
index 2d56a11..1f0a070 100644
--- a/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
+++ b/apps/ui/src/layer/endpoint-dependency/useLayerEndpointDependency.ts
@@ -24,6 +24,7 @@
 import { computed, type Ref } from 'vue';
 import { useQuery } from '@tanstack/vue-query';
 import { useAutoRefreshSubscribe } from '../../controls/useAutoRefreshSubscribe';
+import { useTimeRangeStore } from '../../controls/timeRange';
 import { usePreviewLayerBlock } from '@/controls/previewConfig';
 import { bffClient } from '@/api/client';
 
@@ -34,13 +35,20 @@ export function useLayerEndpointDependency(
 ) {
   // Preview-only: forward the draft `endpointDependency` block.
   const previewCfg = usePreviewLayerBlock(layerKey, 'endpointDependency');
+  const timeRange = useTimeRangeStore();
+  const rangeKey = computed(() => ({
+    step: timeRange.step,
+    startMs: timeRange.range.startMs,
+    endMs: timeRange.range.endMs,
+  }));
   const q = useQuery({
-    queryKey: ['layer-endpoint-dependency', layerKey, service, endpoint, previewCfg],
+    queryKey: ['layer-endpoint-dependency', layerKey, service, endpoint, rangeKey, previewCfg],
     queryFn: () =>
       bffClient.layer.endpointDependency(
         layerKey.value,
         service.value ?? '',
         endpoint.value ?? '',
+        rangeKey.value,
         previewCfg.value,
       ),
     enabled: computed(