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>
