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/07/02 07:39:15 UTC
(skywalking-horizon-ui) branch main updated: fix(traces): drill window snapping + HOUR-view axis-label overlap (#91)
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 14b9ce6 fix(traces): drill window snapping + HOUR-view axis-label overlap (#91)
14b9ce6 is described below
commit 14b9ce6a14675c631777a4735903f98411b44828
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Thu Jul 2 15:39:11 2026 +0800
fix(traces): drill window snapping + HOUR-view axis-label overlap (#91)
* fix(traces): drill window snaps to the clicked hour/day bucket
The window centered on a reconstructed mid-bucket point and capped the
half-window at 6h, so clicking a DAY bucket opened a 12h mid-day slice
(08:35–20:35) instead of that day. Now MINUTE → a ±5min neighborhood,
HOUR → the whole clicked hour, DAY → the whole clicked day (snapped to
the local boundary and spanning one bucket).
* fix(charts): hide overlapping x-axis labels on HOUR-step line charts
The x-axis forced a label every ~length/6 buckets, but the HOUR label
carries the date ("07-02 05:00"), so several wide labels collided in a
narrow widget. Add ECharts `hideOverlap` so any colliding label is dropped
(interval stays as the density target).
* fix(traces): address drill re-review findings + trim comments
- TimeChart: a line-body click carries no dataIndex/value, so recover the
bucket from the click pixel and read the value from series data — clicks
between dots now drill, and a real 0 value still drills.
- Traces tab: defer the drill's first query until the instance/endpoint id
resolves (no unfiltered wrong-scope flash), and seed the endpoint keyword
so a >50-endpoint service still resolves the drilled endpoint.
- Drill window: HOUR/DAY snap via local calendar arithmetic (DST-safe).
- Trim the feature's comments to the non-obvious minimum.
---
apps/bff/src/http/query/dashboard.ts | 3 +-
apps/bff/src/logic/dashboard/schema.ts | 3 +-
apps/ui/src/components/charts/TimeChart.vue | 41 ++++++++++--------
.../admin/layer-templates/WidgetEditorCanvas.vue | 7 ---
apps/ui/src/layer/traces/LayerTracesView.vue | 20 ++++-----
.../render/layer-dashboard/LayerDashboardsView.vue | 50 ++++++++++------------
.../src/render/layer-dashboard/LayerWidgetTile.vue | 6 ---
packages/api-client/src/dashboard.ts | 30 +++----------
8 files changed, 60 insertions(+), 100 deletions(-)
diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts
index 73e0944..0732629 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -296,8 +296,7 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar
// in Step 3. Probes here only run when such gates are present, so
// the common all-self-gate templates keep today's query cost.
const entityGated = widgets.some((w) => vwOf(w)?.kind === 'entity');
- // Group-gate probes keyed by expression: a group gate is probed once at
- // the active entity scope and shared by every widget that carries it.
+ // Each unique group-gate expression is probed once, shared across widgets.
const groupGates = new Map<string, { expression: string }>();
for (const w of widgets) {
const vw = vwOf(w);
diff --git a/apps/bff/src/logic/dashboard/schema.ts b/apps/bff/src/logic/dashboard/schema.ts
index 0aea8a8..bdfd84a 100644
--- a/apps/bff/src/logic/dashboard/schema.ts
+++ b/apps/bff/src/logic/dashboard/schema.ts
@@ -73,8 +73,7 @@ const leafWidgetSchema = z.object({
])
.optional()
.catch(undefined),
- // Metric→trace drill opt-in. Tolerant like `visibleWhen`: an unknown/legacy
- // value maps to `undefined` (no drill) rather than failing the widget parse.
+ // Tolerant like `visibleWhen`: a bad value maps to undefined, not a parse fail.
traceDrill: z
.object({ mode: z.enum(['off', 'latency', 'error']) })
.optional()
diff --git a/apps/ui/src/components/charts/TimeChart.vue b/apps/ui/src/components/charts/TimeChart.vue
index 46b6844..1028973 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -64,9 +64,7 @@ const props = withDefaults(
* these replace the default relative `-Nm` markers — e.g. a caller
* with a known window can pass `mm:ss` elapsed labels. */
xLabels?: string[];
- /** Make datapoints/lines clickable (pointer cursor + `pointClick`). */
clickable?: boolean;
- /** Hide the hover tooltip while a drill popover is pinned on this widget. */
tipSuppressed?: boolean;
}>(),
{
@@ -75,16 +73,12 @@ const props = withDefaults(
},
);
-/** Raw datapoint hit — the host turns `dataIndex`→time and `value`→criterion.
- * `x`/`y` are viewport coords for anchoring a popover. */
const emit = defineEmits<{
pointClick: [
{ seriesIndex: number; dataIndex: number; value: number; seriesName: string; x: number; y: number },
];
}>();
-// Captured on mousedown (precedes the ECharts click) so the emit can report
-// where the point was hit.
const lastPointer = ref<{ x: number; y: number }>({ x: 0, y: 0 });
function onPointerDown(e: MouseEvent): void {
lastPointer.value = { x: e.clientX, y: e.clientY };
@@ -315,7 +309,10 @@ function buildOption(): echarts.EChartsCoreOption {
type: 'category',
data: xLabels,
axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
- axisLabel: { color: '#64748b', fontSize: 9, interval: Math.floor(length / 6) },
+ // hideOverlap drops any label that would collide — wide HOUR labels
+ // ("07-02 05:00") otherwise overlap once the ~length/6 interval isn't
+ // enough. interval stays as the density target; hideOverlap is the guard.
+ axisLabel: { color: '#64748b', fontSize: 9, interval: Math.floor(length / 6), hideOverlap: true },
splitLine: { show: false },
},
/* Dual y-axis when any series asks for axis 1. Right axis label
@@ -384,7 +381,6 @@ function buildOption(): echarts.EChartsCoreOption {
symbol: 'circle',
symbolSize: 4,
showSymbol: true,
- // triggerLineEvent: make the whole line a click target, not just the 4px symbol.
...(props.clickable ? { triggerLineEvent: true, cursor: 'pointer' } : {}),
yAxisIndex: s.yAxisIndex ?? 0,
lineStyle: { width: 1.5 },
@@ -426,15 +422,26 @@ onMounted(() => {
chart = echarts.init(container.value, null, { renderer: 'canvas' });
chart.setOption(buildOption());
prevFingerprint = seriesFingerprint(props.series);
- // Registered unconditionally (clickable can be toggled on AFTER mount) and
- // gated inside on `props.clickable`. Ignores legend/axis clicks and gaps.
+ // A line-body click may lack dataIndex/value — recover from the click pixel
+ // and read the value from series data.
chart.on('click', (p) => {
- if (!props.clickable) return;
- if (p.componentType !== 'series' || typeof p.value !== 'number') return;
+ if (!props.clickable || p.componentType !== 'series' || !chart) return;
+ const si = p.seriesIndex ?? 0;
+ let di = typeof p.dataIndex === 'number' ? p.dataIndex : -1;
+ if (di < 0 && container.value) {
+ const r = container.value.getBoundingClientRect();
+ const at = chart.convertFromPixel({ gridIndex: 0 }, [
+ lastPointer.value.x - r.left,
+ lastPointer.value.y - r.top,
+ ]);
+ if (Array.isArray(at) && typeof at[0] === 'number') di = Math.round(at[0]);
+ }
+ const val = props.series[si]?.data[di];
+ if (di < 0 || typeof val !== 'number') return;
emit('pointClick', {
- seriesIndex: p.seriesIndex ?? 0,
- dataIndex: p.dataIndex ?? 0,
- value: p.value,
+ seriesIndex: si,
+ dataIndex: di,
+ value: val,
seriesName: typeof p.seriesName === 'string' ? p.seriesName : '',
x: lastPointer.value.x,
y: lastPointer.value.y,
@@ -486,14 +493,10 @@ watch(
prevFingerprint = seriesFingerprint(props.series);
},
);
-// Toggled after mount (the drill on/off flag) → rebuild series so
-// triggerLineEvent + pointer cursor apply to the whole line.
watch(
() => props.clickable,
() => chart?.setOption(buildOption(), { replaceMerge: ['series'] }),
);
-// While the drill popover is pinned on this widget, hide the crosshair tooltip
-// so the two cards don't stack at the same point (re-enabled on close).
watch(
() => props.tipSuppressed,
(suppressed) => {
diff --git a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
index 0ae147a..cf5f371 100644
--- a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
+++ b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
@@ -568,17 +568,12 @@ function setWidgetFormat(v: string): void {
else delete w.format;
}
-// Metric→trace drill on a `line` widget. Empty ⇒ no drill. Only meaningful on
-// a layer that serves native traces (GENERAL); the renderer ignores it elsewhere.
function setWidgetTraceDrill(v: string): void {
const w = editingWidget.value;
if (!w) return;
if (v === 'latency' || v === 'error' || v === 'off') w.traceDrill = { mode: v };
else delete w.traceDrill;
}
-// The drill only works when THIS layer's Traces component is on in native (or
-// both) mode — the native trace view consumes the drill filter; Zipkin ignores
-// it. When off, the control is disabled with a notice.
const layerTracesEnabled = computed<boolean>(() => {
const tpl = props.draft.template;
if (!tpl?.components?.traces) return false;
@@ -1328,8 +1323,6 @@ onBeforeUnmount(() => {
<code>line</code> each is a series. Label / unit / axis apply per expression.
</p>
</div>
- <!-- Metric→trace drill: line widgets only, enabled only when this
- layer's native Traces component is on. -->
<div v-if="editingWidget.type === 'line'" class="d-section">
<span class="d-label">Trace drill</span>
<div class="d-row">
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue b/apps/ui/src/layer/traces/LayerTracesView.vue
index 939f1d8..e0dbf77 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -261,13 +261,10 @@ function removeTag(i: number): void {
tagsList.value = tagsList.value.filter((_, idx) => idx !== i);
}
-// Metric→trace drill deep-link. The dashboard opens this tab with the filter
-// pre-seeded via route query (?dMode=&dValue=&dFrom=&dTo=&dInstance=&dEndpoint=
-// &dNonce=). Instance/endpoint arrive by NAME and resolve to ids from this tab's
-// own lists; `dNonce` changes per click so a drill→drill nav re-fires.
+// Metric→trace drill deep-link: filter pre-seeded via route query
+// (?dMode/dValue/dFrom/dTo/dInstance/dEndpoint/dNonce); names resolve to ids here.
const pendingDrillInstance = ref<string | null>(null);
const pendingDrillEndpoint = ref<string | null>(null);
-// The first query defers until the service name resolves (async from landing).
const drillArmed = ref<boolean>(false);
function resolveDrillEntities(): boolean {
let changed = false;
@@ -282,7 +279,9 @@ function resolveDrillEntities(): boolean {
return changed;
}
function maybeRunDrill(): void {
+ // Wait for service + any pending entity id, else the first query runs unfiltered.
if (!drillArmed.value || !serviceName.value) return;
+ if (pendingDrillInstance.value || pendingDrillEndpoint.value) return;
drillArmed.value = false;
runQuery();
}
@@ -290,7 +289,6 @@ function applyDrillFromRoute(): void {
const q = route.query;
const mode = typeof q.dMode === 'string' ? q.dMode : null;
if (mode !== 'latency' && mode !== 'error') return;
- // Cascade-clear: reset the filter surface, then seed the drill criterion.
traceState.value = mode === 'error' ? 'ERROR' : 'ALL';
queryOrder.value = mode === 'latency' ? 'BY_DURATION' : 'BY_START_TIME';
minDuration.value =
@@ -305,18 +303,18 @@ function applyDrillFromRoute(): void {
}
pendingDrillInstance.value = typeof q.dInstance === 'string' ? q.dInstance : null;
pendingDrillEndpoint.value = typeof q.dEndpoint === 'string' ? q.dEndpoint : null;
+ // Seed the endpoint keyword so a >50-endpoint service still fetches this one.
+ if (pendingDrillEndpoint.value) endpointQuery.value = pendingDrillEndpoint.value;
resolveDrillEntities();
drillArmed.value = true;
maybeRunDrill();
}
-// Run the drill once the service resolves (service-scope drills), and re-run
-// once the id lists arrive so an instance/endpoint drill picks up its filter.
watch(serviceName, maybeRunDrill);
watch([instances, endpoints], () => {
- if (!pendingDrillInstance.value && !pendingDrillEndpoint.value) return;
- if (resolveDrillEntities() && !drillArmed.value) runQuery();
+ if (!drillArmed.value) return;
+ resolveDrillEntities();
+ maybeRunDrill();
});
-// Seed on mount and on every subsequent drill navigation (dNonce changes).
watch(() => route.query.dNonce, applyDrillFromRoute, { immediate: true });
const selectedTraceId = ref<string | null>(null);
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index 223706d..49c19cb 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -392,9 +392,7 @@ const fetchKey = computed(
`${layerKey.value}|${scope.value}|${serviceName.value ?? ''}|${selectedInstance.value ?? ''}|${selectedEndpoint.value ?? ''}|${timeRange.range.startMs}|${timeRange.range.endMs}|${timeRange.step}`,
);
const lastFreshKey = ref<string | null>(null);
-// `immediate`: a warm cache on remount (Back from Traces within staleTime)
-// populates `data` synchronously with no change event — without it the
-// "Reading data…" gate hangs until the next auto-refresh.
+// `immediate` marks a warm-cache remount fresh, else the "Reading data…" gate hangs.
watch(
data,
(d) => {
@@ -458,10 +456,7 @@ function widgetColor(w: { id?: string; title?: string; expressions?: string[] })
return colorForMetric(w.id || w.title || w.expressions?.[0] || '');
}
-// Metric→trace drill. Two gates only: the widget opts in via `traceDrill`, and
-// the layer has its Traces component on in NATIVE (or both) mode — the native
-// trace view is the one that consumes the drill's minDuration/state filter;
-// Zipkin's view ignores them. Not bound to any specific layer.
+// Native-trace layers only — the Zipkin view can't consume the drill filter.
const router = useRouter();
const layerTracesEnabled = computed<boolean>(() => {
if (layer.value?.caps?.traces !== true) return false;
@@ -473,24 +468,31 @@ function traceDrillMode(w: DashboardWidget): 'latency' | 'error' | null {
const m = w.traceDrill?.mode;
return m === 'latency' || m === 'error' ? m : null;
}
-// Half-window (ms) around the clicked bucket, scaled to step, capped at 6h.
-const DRILL_STEP_MS: Record<string, number> = { MINUTE: 60_000, HOUR: 3_600_000, DAY: 86_400_000 };
-function drillHalfWindowMs(): number {
- const step = DRILL_STEP_MS[timeRange.step] ?? 60_000;
- return Math.min(Math.max(5 * 60_000, step / 2), 6 * 60 * 60_000);
-}
function drillCenterMs(dataIndex: number, len: number): number {
const { startMs, endMs } = timeRange.range;
if (len <= 1) return endMs;
return Math.round(startMs + ((endMs - startMs) * dataIndex) / (len - 1));
}
-// datetime-local wall-clock — must match the Traces tab's custom-range format.
+// MINUTE → ±5min; HOUR/DAY → the clicked hour/day (local calendar bucket, DST-safe).
+function drillWindow(dataIndex: number, len: number): { fromMs: number; toMs: number; labelMs: number } {
+ const center = drillCenterMs(dataIndex, len);
+ if (timeRange.step === 'MINUTE') {
+ const half = 5 * 60_000;
+ return { fromMs: center - half, toMs: center + half, labelMs: center };
+ }
+ const from = new Date(center);
+ from.setMinutes(0, 0, 0);
+ if (timeRange.step === 'DAY') from.setHours(0);
+ const to = new Date(from);
+ if (timeRange.step === 'DAY') to.setDate(to.getDate() + 1);
+ else to.setHours(to.getHours() + 1);
+ return { fromMs: from.getTime(), toMs: to.getTime(), labelMs: from.getTime() };
+}
function toLocalInput(ms: number): string {
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
-// Pinned drill hint; anchored to the grid so re-clicking repositions.
const gridEl = ref<HTMLElement | null>(null);
const drill = ref<{
widgetId: string;
@@ -507,19 +509,15 @@ function onDrillPoint(
const mode = traceDrillMode(w);
if (!mode) return;
const len = resultsById.value.get(w.id)?.series?.[0]?.data.length ?? 0;
- const center = drillCenterMs(p.dataIndex, len);
- const half = drillHalfWindowMs();
+ const win = drillWindow(p.dataIndex, len);
const ms = Math.max(0, Math.round(p.value));
const query: Record<string, string> = {
dMode: mode,
- dFrom: toLocalInput(center - half),
- dTo: toLocalInput(center + half),
- // Unique per click so a drill→drill navigation on the same tab re-fires.
- dNonce: `${center}:${p.dataIndex}:${w.id}`,
+ dFrom: toLocalInput(win.fromMs),
+ dTo: toLocalInput(win.toMs),
+ dNonce: `${win.fromMs}:${p.dataIndex}:${w.id}`,
};
if (mode === 'latency') query.dValue = String(ms);
- // service (id) seeds the fresh tab's selection; instance/endpoint go by name
- // and the Traces tab resolves the id from its own lists.
if (selectedId.value) query.service = selectedId.value;
if (scope.value === 'instance' && selectedInstance.value) query.dInstance = selectedInstance.value;
if (scope.value === 'endpoint' && selectedEndpoint.value) query.dEndpoint = selectedEndpoint.value;
@@ -531,7 +529,7 @@ function onDrillPoint(
title: w.title,
meta:
band +
- t('around {t}', { t: bucketTimeLabel(timeRange.step, center) }) +
+ t('around {t}', { t: bucketTimeLabel(timeRange.step, win.labelMs) }) +
(mode === 'latency' ? ` · ≥ ${ms} ms` : ''),
label: mode === 'latency' ? t('View slow traces') : t('View error traces'),
};
@@ -793,9 +791,6 @@ const tabHostCtx = computed<TabHostCtx>(() => ({
</div>
</template>
- <!-- Metric→trace drill hint: pinned at the clicked datapoint, offers the
- pre-filtered Traces link. Anchored to the grid so clicking another
- point repositions instead of dismissing. -->
<FloatingPanel
:open="!!drill"
:anchor="gridEl"
@@ -1001,7 +996,6 @@ const tabHostCtx = computed<TabHostCtx>(() => ({
}
}
-/* Metric→trace drill hint (inside the teleported FloatingPanel). */
.drill-pop {
display: flex;
flex-direction: column;
diff --git a/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue b/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
index 0a6809a..bc37dbc 100644
--- a/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
@@ -72,16 +72,11 @@ const props = defineProps<{
hasTopData: (w: { id: string; type: string }) => boolean;
popOutTopList: (id: string) => void;
setTopListRef: (id: string, el: unknown) => void;
- // Metric→trace drill. `traceDrillMode` returns the resolved criterion for a
- // line widget (null ⇒ not drill-eligible); `onDrillPoint` receives the raw
- // chart click and the host turns it into a trace-tab link.
traceDrillMode: (w: DashboardWidget) => 'latency' | 'error' | null;
onDrillPoint: (
w: DashboardWidget,
p: { seriesIndex: number; dataIndex: number; value: number; seriesName: string; x: number; y: number },
) => void;
- /** Widget id whose drill popover is currently open (null when none) —
- * suppresses that chart's hover tooltip so it doesn't stack under it. */
drillOpenId: string | null;
// Compare-mode helpers (null-safe: only read when compareMode is true).
compareHue: (key: string) => string;
@@ -384,7 +379,6 @@ function chipRows(w: DashboardWidget, res: DashboardWidgetResult | undefined): C
color: var(--sw-fg-0);
border-color: var(--sw-line-2);
}
-/* Drill indicator on a line widget — its datapoints open traces on click. */
.drill-flag {
display: inline-flex;
align-items: center;
diff --git a/packages/api-client/src/dashboard.ts b/packages/api-client/src/dashboard.ts
index 181e416..6941ec8 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -89,26 +89,10 @@ export type VisibleWhen =
| { kind: 'entity'; attribute: string; op: 'eq'; value: string };
/**
- * Opt a `line` widget into metric→trace drill-down. Config-driven, not bound
- * to any specific layer: when this is set AND the layer's Traces component is
- * on in native (or both) mode, clicking a datapoint offers a link into the
- * pre-filtered native Traces tab, centered on the clicked bucket and scoped to
- * the active service / instance / endpoint. (Zipkin-only layers are excluded —
- * their trace view doesn't consume the minDuration/state filter.)
- *
- * The `mode` is the whole "link" — no metric↔trace mapping table, no
- * inference at render time. It declares how the click's Y-value is read:
- * - `latency` — the clicked value is a duration in ms; the trace query
- * opens slowest-first (`queryOrder: BY_DURATION`) with
- * `minTraceDuration` = the clicked value. Requires the
- * widget's series to be an unscaled ms duration
- * (`*_resp_time`, `*_percentile`, `*_mq_consume_latency`).
- * - `error` — the value is a ratio (sla / apdex); the trace query opens
- * with `traceState: ERROR`. The Y-value is ignored.
- * - `off` — explicitly suppress the drill on an otherwise-eligible
- * widget (e.g. a combined throughput+latency chart).
- *
- * Absent ⇒ no drill (the renderer never guesses from the metric name).
+ * Opt a `line` widget into metric→trace drill-down (only on a native-trace
+ * layer). `latency` opens slowest-first traces with `minTraceDuration` = the
+ * clicked ms value; `error` opens `traceState: ERROR`; `off` suppresses it.
+ * Absent ⇒ no drill.
*/
export type TraceDrill = { mode: 'off' | 'latency' | 'error' };
@@ -255,11 +239,7 @@ export interface DashboardWidget {
* own order — `asc` (worst/lowest first, e.g. success-rate) vs `des`.
* Absent for non-`top_n` widgets; the UI then falls back to `des`. */
topNOrder?: 'asc' | 'des';
- /**
- * Optional metric→native-trace drill-down — see {@link TraceDrill}. Only
- * meaningful on `line` widgets in a layer that exposes a `trace` scope.
- * Absent ⇒ the widget offers no trace drill.
- */
+ /** Optional metric→trace drill on a `line` widget — see {@link TraceDrill}. */
traceDrill?: TraceDrill;
/** Legacy 24-col grid coordinates — kept for back-compat during the
* span-based flow-layout migration. New widgets should leave these