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

commit 601eeb49930153808c6270e68889b0490e246efc
Author: Wu Sheng <[email protected]>
AuthorDate: Thu May 21 17:25:26 2026 +0800

    dashboard: honor the time picker — step + window, real x-axis labels
    
    The dashboard route hardcoded step:MINUTE and a fixed last-60-min window,
    dropping the SPA's step/start/end — so picking 30 days still fetched the
    last hour at minute precision and the chart labeled the axis "-Nm". The
    route now accepts step (MINUTE/HOUR/DAY) + startMs/endMs, formats the OAP
    date strings per step (verifyDateTimeString-safe), and threads the step
    through buildFragment + the instance/endpoint lookups. Line widgets now
    label the axis with real times per step (HH:mm / MM-DD HH:00 / MM-DD).
    Validated on demo: 1h→61 MINUTE, 24h→25 HOUR, 30d→31 DAY buckets.
---
 apps/bff/src/http/query/dashboard.test.ts          | 18 ++++++-
 apps/bff/src/http/query/dashboard.ts               | 62 +++++++++++++++++-----
 apps/ui/src/components/charts/TimeChart.vue        |  8 +--
 .../render/layer-dashboard/LayerDashboardsView.vue | 25 ++++++++-
 4 files changed, 95 insertions(+), 18 deletions(-)

diff --git a/apps/bff/src/http/query/dashboard.test.ts 
b/apps/bff/src/http/query/dashboard.test.ts
index ac34fa9..1aa6b13 100644
--- a/apps/bff/src/http/query/dashboard.test.ts
+++ b/apps/bff/src/http/query/dashboard.test.ts
@@ -26,7 +26,7 @@ import {
   type MqeResultShape,
 } from './dashboard.js';
 
-const W: Window = { start: '2026-05-17 1000', end: '2026-05-17 1100' };
+const W: Window = { start: '2026-05-17 1000', end: '2026-05-17 1100', step: 
'MINUTE' };
 
 /** Extract just the `entity: { ... }` literal from a built fragment.
  *  Lets assertions target the actual entity-construction logic without
@@ -132,6 +132,7 @@ describe('buildFragment — entity scope construction', () => 
{
     const frag = buildFragment('w7', 'service_cpm', 'frontend', true, {
       start: '2026-05-17 1000',
       end: '2026-05-17 1100',
+      step: 'MINUTE',
     });
     expect(frag.trimStart().startsWith('w7: execExpression(')).toBe(true);
     expect(frag).toContain('expression: "service_cpm"');
@@ -140,6 +141,21 @@ describe('buildFragment — entity scope construction', () 
=> {
     expect(frag).toContain('step: MINUTE');
   });
 
+  it('duration step follows the window step (HOUR / DAY)', () => {
+    const hour = buildFragment('w8', 'service_cpm', 'frontend', true, {
+      start: '2026-05-17 10',
+      end: '2026-05-18 10',
+      step: 'HOUR',
+    });
+    expect(hour).toContain('step: HOUR');
+    const day = buildFragment('w9', 'service_cpm', 'frontend', true, {
+      start: '2026-04-17',
+      end: '2026-05-17',
+      step: 'DAY',
+    });
+    expect(day).toContain('step: DAY');
+  });
+
   it('result block requests metric.labels + owner fields (TopList / relabels 
support)', () => {
     const frag = buildFragment('w0', 'm', 'svc', true, W);
     expect(frag).toContain('metric { labels { key value } }');
diff --git a/apps/bff/src/http/query/dashboard.ts 
b/apps/bff/src/http/query/dashboard.ts
index 0b71d91..8bcffa8 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -115,6 +115,14 @@ const bodySchema = z.object({
   // an accidentally-huge template never reaches OAP.
   widgets: z.array(widgetSchema).max(40).optional(),
   scope: scopeSchema.optional(),
+  /** Global time-range, forwarded by the SPA's time picker. When all
+   *  three are present the BFF queries OAP at the requested precision
+   *  and window; otherwise it falls back to the last-hour MINUTE
+   *  default. `step` must match OAP's downsampling tiers and drives the
+   *  date-string format (verifyDateTimeString rejects a mismatch). */
+  step: z.enum(['MINUTE', 'HOUR', 'DAY']).optional(),
+  startMs: z.number().int().positive().optional(),
+  endMs: z.number().int().positive().optional(),
 });
 
 interface MqeOwner {
@@ -174,23 +182,48 @@ 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 {
-  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}`;
+  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) };
+  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,
+  };
 }
 
 /** Build one aliased `execExpression` GraphQL fragment for a single
@@ -244,7 +277,7 @@ export function buildFragment(
     `${alias}: execExpression(\n` +
     `      expression: ${JSON.stringify(expression)},\n` +
     `      entity: ${entity},\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} }\n` +
     `    ) {\n` +
     `      type error\n` +
     `      results {\n` +
@@ -383,13 +416,18 @@ export function registerDashboardQueryRoute(app: 
FastifyInstance, deps: Dashboar
       let normal = true;
       const cfgCurrent = deps.config.current;
       const opts = buildOapOpts(cfgCurrent, deps.fetch);
-      const window = defaultWindow();
+      // Honor the SPA's time picker (step + start/end). Falls back to
+      // the last-hour MINUTE default when the caller omits the range.
+      const window =
+        parsed.data.step && parsed.data.startMs && parsed.data.endMs
+          ? windowFromRange(parsed.data.step, parsed.data.startMs, 
parsed.data.endMs) ?? defaultWindow()
+          : defaultWindow();
 
       const baseResp: DashboardResponse = {
         layer: layerKey,
         service: serviceName || null,
         generatedAt: Date.now(),
-        step: 'MINUTE',
+        step: window.step,
         durationStart: window.start,
         durationEnd: window.end,
         widgets: [],
@@ -457,7 +495,7 @@ export function registerDashboardQueryRoute(app: 
FastifyInstance, deps: Dashboar
             LIST_FIRST_INSTANCE,
             {
               serviceId,
-              duration: { start: window.start, end: window.end, step: 'MINUTE' 
},
+              duration: { start: window.start, end: window.end, step: 
window.step },
             },
           );
           selectedInstance = data.instances?.[0]?.name ?? null;
@@ -472,7 +510,7 @@ export function registerDashboardQueryRoute(app: 
FastifyInstance, deps: Dashboar
             FIND_FIRST_ENDPOINT,
             {
               serviceId,
-              duration: { start: window.start, end: window.end, step: 'MINUTE' 
},
+              duration: { start: window.start, end: window.end, step: 
window.step },
             },
           );
           selectedEndpoint = data.endpoints?.[0]?.name ?? null;
diff --git a/apps/ui/src/components/charts/TimeChart.vue 
b/apps/ui/src/components/charts/TimeChart.vue
index 3c835f6..0efb423 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -111,10 +111,10 @@ const container = ref<HTMLDivElement | null>(null);
 let chart: EChartsType | null = null;
 
 function buildOption(): echarts.EChartsCoreOption {
-  // Generate equal-spaced bucket indices for the x-axis. We don't have
-  // explicit timestamps from the BFF response (the duration window is
-  // implied to be MINUTE-stepped over the last 15m), so we label the
-  // axis with relative "-Nm" markers.
+  // X-axis labels: callers that know the window (e.g. the layer
+  // dashboard, which reconstructs per-bucket times from the active
+  // step + range) pass explicit `xLabels`. When absent, fall back to
+  // relative "-Nm" markers.
   const length = props.series[0]?.data.length ?? 0;
   const xLabels =
     props.xLabels && props.xLabels.length === length
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 94ac133..9a14baf 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -37,7 +37,7 @@ import { useLayerPageOrchestrator } from 
'@/render/layer-dashboard/useLayerPageO
 import { useLayerEndpoints } from '@/layer/useLayerEndpoints';
 import { useLayerInstances } from '@/layer/useLayerInstances';
 import { useLayerLanding } from '@/layer/useLayerLanding';
-import { useTimeRangeStore } from '@/controls/timeRange';
+import { useTimeRangeStore, type TimeStep } from '@/controls/timeRange';
 import { pushEvent } from '@/controls/eventLog';
 import { useLayers } from '@/shell/useLayers';
 import { useSelectedEndpoint } from '@/layer/useSelectedEndpoint';
@@ -93,6 +93,28 @@ const rangeRef = computed(() => {
   const r = timeRange.range;
   return { step: timeRange.step, startMs: r.startMs, endMs: r.endMs };
 });
+
+// Time-axis labels for line widgets. The BFF returns bucket VALUES only
+// (no per-bucket timestamp), so we reconstruct evenly-spaced labels from
+// the active window + step — the buckets are uniform, so spacing N points
+// across [start, end] matches OAP's bucketing. Formatted browser-local
+// (the app displays browser-local; ECharts handles ms→local elsewhere).
+function fmtBucket(step: TimeStep, ms: number): string {
+  const d = new Date(ms);
+  const z = (n: number) => String(n).padStart(2, '0');
+  if (step === 'DAY') return `${z(d.getMonth() + 1)}-${z(d.getDate())}`;
+  if (step === 'HOUR') return `${z(d.getMonth() + 1)}-${z(d.getDate())} 
${z(d.getHours())}:00`;
+  return `${z(d.getHours())}:${z(d.getMinutes())}`;
+}
+function xLabelsForLen(len: number): string[] {
+  if (len <= 0) return [];
+  const { startMs, endMs } = timeRange.range;
+  const step = timeRange.step;
+  if (len === 1) return [fmtBucket(step, endMs)];
+  return Array.from({ length: len }, (_, i) =>
+    fmtBucket(step, startMs + ((endMs - startMs) * i) / (len - 1)),
+  );
+}
 const landing = useLayerLanding(safeLayer, safeCfg, rangeRef);
 const serviceName = computed<string | null>(() => {
   const rows = landing.data.value?.sampledRows ?? landing.rows.value ?? [];
@@ -710,6 +732,7 @@ function isVisible(
               :height="(w.rowSpan ?? 1) * 110 - 50"
               :accent="widgetColor(w)"
               :format="w.format"
+              
:x-labels="xLabelsForLen(resultsById.get(w.id)!.series![0]?.data.length ?? 0)"
             />
             <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) 
? 'loading…' : 'no data' }}</span>
           </template>

Reply via email to