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 a9922bd feat(traces): drill from General-service metric charts to
native traces (#90)
a9922bd is described below
commit a9922bd920591efa10310c092575825e5ced9a0e
Author: 吴晟 Wu Sheng <[email protected]>
AuthorDate: Thu Jul 2 09:13:53 2026 +0800
feat(traces): drill from General-service metric charts to native traces
(#90)
Implemented —
[apache/skywalking-horizon-ui#90](https://github.com/apache/skywalking-horizon-ui/pull/90).
It's **config-driven**, not tied to a specific layer. The whole thing hangs
on two conditions: a line widget declares a `traceDrill` mode, and its layer
has the **Traces component activated**. Nothing is hardcoded to General service
— that's just where the bundled widgets ship with it turned on.
The reason it stays simple: only latency and success/error metrics have a
natural trace criterion, so the drill is driven by the declared **mode** rather
than any metric↔trace mapping.
**How it works** — a drill-capable line widget shows a small **traces**
flag in its header and its datapoints are clickable. Click a point and it opens
the native Traces list **in a new browser tab**, pre-filtered to that service
(and the selected instance / endpoint) and centered on the clicked bucket's
window:
- **latency** widgets (response time / percentiles) → slowest-first,
`minTraceDuration` = the value you clicked (click p99 → the tail; click p50 →
median-and-slower).
- **error** widgets (error rate / success rate / Apdex) → the trace list
filtered to ERROR status.
Throughput (cpm) and CPU / memory / GC gauges have no meaningful "which
traces" answer, so they offer no drill.
**Enabling it** — it's a per-widget config field (`traceDrill: latency |
error`) authored in the dashboard widget editor. The editor only lets you set
it when the layer's Traces component is on. So it works on any dashboard —
bundled or your own custom ones — as long as that layer has traces. The bundled
**General service** layer ships these ten enabled:
| Scope | Widget | Mode |
|---|---|---|
| Service | Avg Response Time | latency |
| Service | Response Time Percentile | latency |
| Service | Error Rate | error |
| Service | Apdex | error |
| Instance | Service Instance Latency | latency |
| Instance | Service Instance Success Rate | error |
| Endpoint | Response Time | latency |
| Endpoint | Response Time Percentile | latency |
| Endpoint | Success Rate | error |
| Endpoint | MQ Avg Consuming Latency | latency |
No backend change was needed — `minTraceDuration` and `traceState` already
exist on the trace query, so it's a client-side classify-then-navigate.
---
CHANGELOG.md | 6 +
apps/bff/src/bundled_templates/layers/general.json | 10 ++
apps/bff/src/http/query/dashboard.test.ts | 29 ----
apps/bff/src/http/query/dashboard.ts | 26 +--
apps/bff/src/logic/dashboard/mqe.ts | 9 +-
apps/bff/src/logic/dashboard/schema.ts | 7 +-
apps/ui/src/components/charts/TimeChart.vue | 53 +++++-
.../admin/layer-templates/WidgetEditorCanvas.vue | 52 +++++-
apps/ui/src/i18n/locales/de.json | 5 +
apps/ui/src/i18n/locales/en.json | 5 +
apps/ui/src/i18n/locales/es.json | 5 +
apps/ui/src/i18n/locales/fr.json | 5 +
apps/ui/src/i18n/locales/ja.json | 5 +
apps/ui/src/i18n/locales/ko.json | 5 +
apps/ui/src/i18n/locales/pt.json | 5 +
apps/ui/src/i18n/locales/zh-CN.json | 5 +
apps/ui/src/layer/traces/LayerTracesView.vue | 58 +++++++
.../render/layer-dashboard/LayerDashboardsView.vue | 183 ++++++++++++++++++++-
.../src/render/layer-dashboard/LayerWidgetTile.vue | 50 ++++++
packages/api-client/src/dashboard.ts | 37 ++++-
20 files changed, 484 insertions(+), 76 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90ae914..09632aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,12 @@ The version line is shared by every package in the monorepo
(apps + shared packa
- **Six instance dashboard line widgets for Node.js runtime metrics** —
process CPU, V8 heap used/total/limit, RSS, and external memory
(`meter_instance_nodejs_*`). Each line widget uses `visibleWhen` so widgets
render only when the Node.js agent reports runtime data.
+### General Service — metric-to-trace drill-down
+
+- **Removed the deprecated "Layer-scoped" widget toggle from the dashboard
editor.** It emitted OAP's `scope: All` entity — deprecated in the
query-protocol since OAP 9.4.0 — and no bundled dashboard used it. Any legacy
stored dashboard that still carries the flag now renders at normal service
scope (the unknown key is ignored). Layer-wide rollups belong on the Overview
dashboards.
+
+- **Click a latency or error point on a General-service chart to open the
matching traces.** Drill-capable line widgets — Avg Response Time, Response
Time Percentile, Error Rate, Apdex, Success Rate, and MQ consume latency,
across the Service, Instance, and Endpoint scopes — carry a **traces** flag in
their header, and their datapoints are clickable. Clicking a point opens the
native Traces list in a **new browser tab**, pre-filtered to that service (and
the selected instance / endpoint) [...]
+
### Profiling
- **Profiling task creation is consistent and tells you upfront what it
needs.** Across all five task types (Trace / eBPF / Network / pprof / Async)
the **New Task** button enables as soon as the basic entity is chosen and
always carries a tooltip, and inside the create box a missing target — no
profilable processes, or no instances on the service — is shown as a clear
message next to a disabled **Create** rather than a silently greyed-out button.
diff --git a/apps/bff/src/bundled_templates/layers/general.json
b/apps/bff/src/bundled_templates/layers/general.json
index 698af60..494aebc 100644
--- a/apps/bff/src/bundled_templates/layers/general.json
+++ b/apps/bff/src/bundled_templates/layers/general.json
@@ -63,6 +63,7 @@
"type": "line",
"unit": "%",
"expressions": ["100 - service_sla/100"],
+ "traceDrill": { "mode": "error" },
"span": 3,
"rowSpan": 2
},
@@ -72,6 +73,7 @@
"tip": "User-satisfaction score on a 0 – 1 scale.",
"type": "line",
"expressions": ["service_apdex/10000"],
+ "traceDrill": { "mode": "error" },
"span": 3,
"rowSpan": 2
},
@@ -84,6 +86,7 @@
"expressions": [
"relabels(service_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='50,75,90,95,99')"
],
+ "traceDrill": { "mode": "latency" },
"span": 3,
"rowSpan": 2
},
@@ -93,6 +96,7 @@
"type": "line",
"unit": "ms",
"expressions": ["service_resp_time"],
+ "traceDrill": { "mode": "latency" },
"span": 3,
"rowSpan": 2
},
@@ -169,6 +173,7 @@
"type": "line",
"unit": "ms",
"expressions": ["service_instance_resp_time"],
+ "traceDrill": { "mode": "latency" },
"span": 4,
"rowSpan": 2
},
@@ -178,6 +183,7 @@
"type": "line",
"unit": "%",
"expressions": ["service_instance_sla/100"],
+ "traceDrill": { "mode": "error" },
"span": 4,
"rowSpan": 2
},
@@ -969,6 +975,7 @@
"type": "line",
"unit": "ms",
"expressions": ["endpoint_resp_time"],
+ "traceDrill": { "mode": "latency" },
"span": 4,
"rowSpan": 2
},
@@ -978,6 +985,7 @@
"type": "line",
"unit": "%",
"expressions": ["endpoint_sla/100"],
+ "traceDrill": { "mode": "error" },
"span": 4,
"rowSpan": 2
},
@@ -989,6 +997,7 @@
"expressions": [
"relabels(endpoint_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='50,75,90,95,99')"
],
+ "traceDrill": { "mode": "latency" },
"span": 12,
"rowSpan": 2
},
@@ -1000,6 +1009,7 @@
"unit": "ms",
"expressions": ["endpoint_mq_consume_latency"],
"visibleWhen": { "kind": "mqe", "expression":
"endpoint_mq_consume_latency", "op": "exists" },
+ "traceDrill": { "mode": "latency" },
"span": 12,
"rowSpan": 2
}
diff --git a/apps/bff/src/http/query/dashboard.test.ts
b/apps/bff/src/http/query/dashboard.test.ts
index 260e0ec..931a0de 100644
--- a/apps/bff/src/http/query/dashboard.test.ts
+++ b/apps/bff/src/http/query/dashboard.test.ts
@@ -74,35 +74,6 @@ describe('buildFragment — entity scope construction', () => {
expect(entity).not.toContain('serviceInstanceName');
});
- it('layerScope:true → {scope: All}, no serviceName / normal / instance /
endpoint in entity', () => {
- const entity = entityOf(
- buildFragment('w0', 'top_n(endpoint_cpm,20,des)', 'frontend', true, W, {
- layerScope: true,
- }),
- );
- expect(entity).toContain('scope: All');
- expect(entity).not.toContain('serviceName');
- expect(entity).not.toContain('serviceInstanceName');
- expect(entity).not.toContain('endpointName');
- expect(entity).not.toContain('normal:');
- });
-
- it('layerScope:true wins over both serviceInstanceName AND endpointName', ()
=> {
- // Defensive: if a caller sets layerScope:true AND instance/endpoint,
- // we should still produce All scope (layerScope is the explicit
- // opt-out from per-entity filtering).
- const entity = entityOf(
- buildFragment('w0', 'endpoint_cpm', 'frontend', true, W, {
- layerScope: true,
- serviceInstanceName: 'should-be-ignored',
- endpointName: '/should/be/ignored',
- }),
- );
- expect(entity).toContain('scope: All');
- expect(entity).not.toContain('should-be-ignored');
- expect(entity).not.toContain('/should/be/ignored');
- });
-
it('serviceInstanceName takes precedence over endpointName when both set',
() => {
const entity = entityOf(
buildFragment('w0', 'm', 'svc', true, W, {
diff --git a/apps/bff/src/http/query/dashboard.ts
b/apps/bff/src/http/query/dashboard.ts
index 4f775de..73e0944 100644
--- a/apps/bff/src/http/query/dashboard.ts
+++ b/apps/bff/src/http/query/dashboard.ts
@@ -296,17 +296,13 @@ 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 + layerScope): a
- // layer-scoped widget's gate must be probed at `{ scope: All }` and
- // a normally-scoped one at the active entity scope, so the two can
- // never share a verdict.
- const gateKey = (expr: string, layerScope: boolean): string =>
`${layerScope ? 'L' : 'S'}|${expr}`;
- const groupGates = new Map<string, { expression: string; layerScope:
boolean }>();
+ // 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.
+ const groupGates = new Map<string, { expression: string }>();
for (const w of widgets) {
const vw = vwOf(w);
if (vw?.kind === 'mqe' && !isSelfGate(w, vw)) {
- const ls = w.layerScope === true;
- groupGates.set(gateKey(vw.expression, ls), { expression:
vw.expression, layerScope: ls });
+ groupGates.set(vw.expression, { expression: vw.expression });
}
}
// `null` = no entity context / probe failed → entity gates no-op.
@@ -340,9 +336,8 @@ export function registerDashboardQueryRoute(app:
FastifyInstance, deps: Dashboar
try {
const fragments = entries.map(([, g], i) =>
buildFragment(`g${i}`, g.expression, serviceName, normal,
window, {
- layerScope: g.layerScope,
- serviceInstanceName: g.layerScope || !scopeHonorsInstance ?
null : selectedInstance,
- endpointName: g.layerScope || !scopeHonorsEndpoint ? null :
selectedEndpoint,
+ serviceInstanceName: scopeHonorsInstance ? selectedInstance :
null,
+ endpointName: scopeHonorsEndpoint ? selectedEndpoint : null,
coldStage: !!req.coldStage,
}),
);
@@ -369,7 +364,7 @@ export function registerDashboardQueryRoute(app:
FastifyInstance, deps: Dashboar
return;
}
if (!isSelfGate(w, vw)) {
- const vals = groupGateVals.get(gateKey(vw.expression, w.layerScope
=== true));
+ const vals = groupGateVals.get(vw.expression);
if (vals == null) return; // probe failed / missing → fail open
if (!mqeGatePass(vw.op, 'value' in vw ? vw.value : undefined, vals))
skipped.add(i);
}
@@ -409,11 +404,8 @@ export function registerDashboardQueryRoute(app:
FastifyInstance, deps: Dashboar
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,
+ serviceInstanceName: scopeHonorsInstance ? selectedInstance
: null,
+ endpointName: scopeHonorsEndpoint ? selectedEndpoint : null,
coldStage: !!req.coldStage,
}),
);
diff --git a/apps/bff/src/logic/dashboard/mqe.ts
b/apps/bff/src/logic/dashboard/mqe.ts
index 937d8f4..5c5d912 100644
--- a/apps/bff/src/logic/dashboard/mqe.ts
+++ b/apps/bff/src/logic/dashboard/mqe.ts
@@ -56,10 +56,6 @@ export interface MqeResultShape {
/** Build one aliased `execExpression` GraphQL fragment for a single
* widget expression. The entity scope flips based on opts:
- * - `layerScope: true` → `{ scope: All }` (no service filter — GLOBAL,
- * not layer-restricted; use with care since OAP's Entity has no
- * `layer` field, so this leaks across layers if the metric is
- * shared between layers)
* - `serviceInstanceName` set → ServiceInstance scope
* - `endpointName` set → Endpoint scope
* - otherwise → Service scope with the supplied serviceName
@@ -72,7 +68,6 @@ export function buildFragment(
normal: boolean,
w: Window,
opts: {
- layerScope?: boolean;
serviceInstanceName?: string | null;
endpointName?: string | null;
/** When true, splice `coldStage: true` into the Duration literal.
@@ -86,9 +81,7 @@ export function buildFragment(
// owner.endpointName (TopList widgets — top_n() returns a sorted list of
// entities + values).
let entity: string;
- if (opts.layerScope) {
- entity = '{ scope: All }';
- } else if (opts.serviceInstanceName) {
+ if (opts.serviceInstanceName) {
entity =
`{ scope: ServiceInstance, serviceName: ${JSON.stringify(serviceName)},`
+
` serviceInstanceName: ${JSON.stringify(opts.serviceInstanceName)},` +
diff --git a/apps/bff/src/logic/dashboard/schema.ts
b/apps/bff/src/logic/dashboard/schema.ts
index b607004..0aea8a8 100644
--- a/apps/bff/src/logic/dashboard/schema.ts
+++ b/apps/bff/src/logic/dashboard/schema.ts
@@ -73,7 +73,12 @@ const leafWidgetSchema = z.object({
])
.optional()
.catch(undefined),
- layerScope: z.boolean().optional(),
+ // Metric→trace drill opt-in. Tolerant like `visibleWhen`: an unknown/legacy
+ // value maps to `undefined` (no drill) rather than failing the widget parse.
+ traceDrill: z
+ .object({ mode: z.enum(['off', 'latency', 'error']) })
+ .optional()
+ .catch(undefined),
// Legacy x/y/w/h kept optional for back-compat.
x: z.number().int().min(0).optional(),
y: z.number().int().min(0).optional(),
diff --git a/apps/ui/src/components/charts/TimeChart.vue
b/apps/ui/src/components/charts/TimeChart.vue
index a9aacdb..46b6844 100644
--- a/apps/ui/src/components/charts/TimeChart.vue
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -64,6 +64,10 @@ 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;
}>(),
{
height: 180,
@@ -71,6 +75,21 @@ 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 };
+}
+
// Compact magnitude with an SI suffix (k / M / G / T), ~3 significant
// figures, trailing zeros trimmed — `45.1k`, not the scientific `4.51e4`
// operators found unreadable. Axis and tooltip share it so a tick and its
@@ -365,6 +384,8 @@ 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 },
data: s.data.map((v) => (v === null ? '-' : v)),
@@ -405,6 +426,20 @@ 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.
+ chart.on('click', (p) => {
+ if (!props.clickable) return;
+ if (p.componentType !== 'series' || typeof p.value !== 'number') return;
+ emit('pointClick', {
+ seriesIndex: p.seriesIndex ?? 0,
+ dataIndex: p.dataIndex ?? 0,
+ value: p.value,
+ seriesName: typeof p.seriesName === 'string' ? p.seriesName : '',
+ x: lastPointer.value.x,
+ y: lastPointer.value.y,
+ });
+ });
const ro = new ResizeObserver(() => chart?.resize());
ro.observe(container.value);
onBeforeUnmount(() => {
@@ -451,10 +486,26 @@ 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) => {
+ if (!chart) return;
+ if (suppressed) chart.dispatchAction({ type: 'hideTip' });
+ chart.setOption({ tooltip: { show: !suppressed } });
+ },
+);
</script>
<template>
- <div ref="container" class="time-chart" :style="{ height: `${height}px` }" />
+ <div ref="container" class="time-chart" :style="{ height: `${height}px` }"
@mousedown="onPointerDown" />
</template>
<style scoped>
diff --git a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
index 250dddc..0ae147a 100644
--- a/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
+++ b/apps/ui/src/features/admin/layer-templates/WidgetEditorCanvas.vue
@@ -568,6 +568,24 @@ 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;
+ const src = tpl.traces?.source ?? 'native';
+ return src === 'native' || src === 'both';
+});
+
// `format: 'enum'` value→label editor — the valueMap is a coded-value → label
// table (e.g. 1 → OK). Keys are renamed on blur to avoid focus loss mid-edit.
const valueMapEntries = computed<Array<[string, string]>>(() => {
@@ -1310,6 +1328,31 @@ 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">
+ <label>
+ <span>Mode</span>
+ <select
+ :value="editingWidget.traceDrill?.mode ?? ''"
+ :disabled="!layerTracesEnabled"
+ @change="setWidgetTraceDrill(($event.target as
HTMLSelectElement).value)"
+ >
+ <option value="">none</option>
+ <option value="latency">latency → slow traces</option>
+ <option value="error">error → error traces</option>
+ </select>
+ </label>
+ </div>
+ <p v-if="!layerTracesEnabled" class="d-hint drill-off">
+ Disabled — metric→trace drill needs this layer's
<b>Traces</b> component on in native mode; the Zipkin trace view can't consume
the drill filter.
+ </p>
+ <p v-else class="d-hint">
+ Click a datapoint on this widget to open the pre-filtered
Traces tab. <code>latency</code> ⇒ slowest traces ≥ the clicked value;
<code>error</code> ⇒ error-status traces. Centered on the clicked time bucket.
+ </p>
+ </div>
<div class="d-section">
<span class="d-label" :title="visibleWhenHint(activeScope)">
Visible when (optional)
@@ -1353,12 +1396,6 @@ onBeforeUnmount(() => {
</p>
<p class="d-hint" style="white-space: pre-line">{{
visibleWhenHint(activeScope) }}</p>
</div>
- <div class="d-section">
- <label class="d-check">
- <input type="checkbox" v-model="editingWidget.layerScope" />
- <span>Layer-scoped (run MQE across the whole layer, ignore
selected service)</span>
- </label>
- </div>
</template>
</template>
</div>
@@ -2112,6 +2149,9 @@ onBeforeUnmount(() => {
border-radius: 2px;
color: var(--sw-fg-1);
}
+.d-hint.drill-off {
+ color: var(--sw-warn);
+}
.d-check {
display: flex;
align-items: flex-start;
diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 151d68f..c19a3eb 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -508,6 +508,11 @@
"Order": "Reihenfolge",
"Newest": "Neueste",
"Slowest": "Langsamste",
+ "View slow traces": "Langsame Traces anzeigen",
+ "View error traces": "Fehler-Traces anzeigen",
+ "Click a point to view the slowest traces at that time": "Auf einen Punkt
klicken, um die langsamsten Traces zu diesem Zeitpunkt anzuzeigen",
+ "Click a point to view error traces at that time": "Auf einen Punkt klicken,
um die Fehler-Traces zu diesem Zeitpunkt anzuzeigen",
+ "around {t}": "um {t}",
"Cap on trace rows returned (default 30).": "Obergrenze der zurückgegebenen
Trace-Zeilen (Standard 30).",
"Back to presets": "Zurück zu Voreinstellungen",
"Custom…": "Benutzerdefiniert…",
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index 0232c97..667ab15 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -478,6 +478,11 @@
"Order": "Order",
"Newest": "Newest",
"Slowest": "Slowest",
+ "View slow traces": "View slow traces",
+ "View error traces": "View error traces",
+ "Click a point to view the slowest traces at that time": "Click a point to
view the slowest traces at that time",
+ "Click a point to view error traces at that time": "Click a point to view
error traces at that time",
+ "around {t}": "around {t}",
"Cap on trace rows returned (default 30).": "Cap on trace rows returned
(default 30).",
"Back to presets": "Back to presets",
"Custom…": "Custom…",
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 69444ee..3453456 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -508,6 +508,11 @@
"Order": "Orden",
"Newest": "Más recientes",
"Slowest": "Más lentas",
+ "View slow traces": "Ver trazas lentas",
+ "View error traces": "Ver trazas con error",
+ "Click a point to view the slowest traces at that time": "Haz clic en un
punto para ver las trazas más lentas en ese momento",
+ "Click a point to view error traces at that time": "Haz clic en un punto
para ver las trazas con error en ese momento",
+ "around {t}": "alrededor de {t}",
"Cap on trace rows returned (default 30).": "Límite de filas de trazas
devueltas (predeterminado 30).",
"Back to presets": "Volver a los presets",
"Custom…": "Personalizado…",
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index 64e2cc8..a60f65d 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -508,6 +508,11 @@
"Order": "Tri",
"Newest": "Plus récents",
"Slowest": "Plus lents",
+ "View slow traces": "Voir les traces lentes",
+ "View error traces": "Voir les traces en erreur",
+ "Click a point to view the slowest traces at that time": "Cliquez sur un
point pour voir les traces les plus lentes à ce moment",
+ "Click a point to view error traces at that time": "Cliquez sur un point
pour voir les traces en erreur à ce moment",
+ "around {t}": "autour de {t}",
"Cap on trace rows returned (default 30).": "Limite du nombre de lignes de
traces renvoyées (par défaut 30).",
"Back to presets": "Retour aux préréglages",
"Custom…": "Personnalisé…",
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 504e2ef..57ebcbe 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -508,6 +508,11 @@
"Order": "順序",
"Newest": "新しい順",
"Slowest": "遅い順",
+ "View slow traces": "遅いトレースを表示",
+ "View error traces": "エラートレースを表示",
+ "Click a point to view the slowest traces at that time":
"データポイントをクリックしてその時刻の最も遅いトレースを表示",
+ "Click a point to view error traces at that time":
"データポイントをクリックしてその時刻のエラートレースを表示",
+ "around {t}": "{t} 前後",
"Cap on trace rows returned (default 30).": "返されるトレース行数の上限(既定 30)。",
"Back to presets": "プリセットに戻る",
"Custom…": "カスタム…",
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index 3a3b3fa..1772354 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -508,6 +508,11 @@
"Order": "정렬",
"Newest": "최신순",
"Slowest": "느린 순",
+ "View slow traces": "느린 트레이스 보기",
+ "View error traces": "오류 트레이스 보기",
+ "Click a point to view the slowest traces at that time": "데이터 포인트를 클릭하여 해당
시점의 가장 느린 트레이스 보기",
+ "Click a point to view error traces at that time": "데이터 포인트를 클릭하여 해당 시점의 오류
트레이스 보기",
+ "around {t}": "{t} 부근",
"Cap on trace rows returned (default 30).": "반환되는 트레이스 행 수 상한 (기본값 30).",
"Back to presets": "프리셋으로 돌아가기",
"Custom…": "사용자 지정…",
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index 2a9efc9..a544142 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -508,6 +508,11 @@
"Order": "Ordem",
"Newest": "Mais recentes",
"Slowest": "Mais lentos",
+ "View slow traces": "Ver traces lentos",
+ "View error traces": "Ver traces com erro",
+ "Click a point to view the slowest traces at that time": "Clique em um ponto
para ver os traces mais lentos naquele momento",
+ "Click a point to view error traces at that time": "Clique em um ponto para
ver os traces com erro naquele momento",
+ "around {t}": "por volta de {t}",
"Cap on trace rows returned (default 30).": "Limite de linhas de trace
retornadas (padrão 30).",
"Back to presets": "Voltar aos pré-ajustes",
"Custom…": "Personalizado…",
diff --git a/apps/ui/src/i18n/locales/zh-CN.json
b/apps/ui/src/i18n/locales/zh-CN.json
index 0a59f8d..a6e736c 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -508,6 +508,11 @@
"Order": "排序",
"Newest": "最新",
"Slowest": "最慢",
+ "View slow traces": "查看慢 Trace",
+ "View error traces": "查看错误 Trace",
+ "Click a point to view the slowest traces at that time": "点击数据点查看该时刻最慢的
Trace",
+ "Click a point to view error traces at that time": "点击数据点查看该时刻的错误 Trace",
+ "around {t}": "约 {t}",
"Cap on trace rows returned (default 30).": "返回的 trace 行数上限(默认 30)。",
"Back to presets": "返回预设",
"Custom…": "自定义…",
diff --git a/apps/ui/src/layer/traces/LayerTracesView.vue
b/apps/ui/src/layer/traces/LayerTracesView.vue
index b534bda..939f1d8 100644
--- a/apps/ui/src/layer/traces/LayerTracesView.vue
+++ b/apps/ui/src/layer/traces/LayerTracesView.vue
@@ -261,6 +261,64 @@ 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.
+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;
+ if (pendingDrillInstance.value) {
+ const hit = instances.value.find((i) => i.name ===
pendingDrillInstance.value);
+ if (hit) { instanceId.value = hit.id; pendingDrillInstance.value = null;
changed = true; }
+ }
+ if (pendingDrillEndpoint.value) {
+ const hit = endpoints.value.find((e) => e.name ===
pendingDrillEndpoint.value);
+ if (hit) { endpointId.value = hit.id; pendingDrillEndpoint.value = null;
changed = true; }
+ }
+ return changed;
+}
+function maybeRunDrill(): void {
+ if (!drillArmed.value || !serviceName.value) return;
+ drillArmed.value = false;
+ runQuery();
+}
+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 =
+ mode === 'latency' && typeof q.dValue === 'string' &&
Number.isFinite(Number(q.dValue))
+ ? Math.max(0, Math.round(Number(q.dValue)))
+ : null;
+ maxDuration.value = null;
+ if (typeof q.dFrom === 'string' && typeof q.dTo === 'string' && q.dFrom &&
q.dTo) {
+ windowMinutes.value = CUSTOM_RANGE_SENTINEL;
+ customStart.value = q.dFrom;
+ customEnd.value = q.dTo;
+ }
+ pendingDrillInstance.value = typeof q.dInstance === 'string' ? q.dInstance :
null;
+ pendingDrillEndpoint.value = typeof q.dEndpoint === 'string' ? q.dEndpoint :
null;
+ 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();
+});
+// Seed on mount and on every subsequent drill navigation (dNonce changes).
+watch(() => route.query.dNonce, applyDrillFromRoute, { immediate: true });
+
const selectedTraceId = ref<string | null>(null);
const selectedTraceIds = ref<string[]>([]);
const selectedRowKey = 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 4123fa6..223706d 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -27,8 +27,9 @@
-->
<script setup lang="ts">
import { computed } from 'vue';
-import { useRoute } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
import type { LayerDef, DashboardWidget } from
'@skywalking-horizon-ui/api-client';
+import FloatingPanel from '@/components/primitives/FloatingPanel.vue';
import type { TabHostCtx } from '@/render/widgets/tabHostCtx';
import LayerInstancePicker from
'@/render/layer-dashboard/LayerInstancePicker.vue';
import LayerEndpointPicker from
'@/render/layer-dashboard/LayerEndpointPicker.vue';
@@ -391,11 +392,16 @@ 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);
-watch(data, (d) => {
- if (d !== null && d !== undefined) {
- lastFreshKey.value = fetchKey.value;
- }
-});
+// `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.
+watch(
+ data,
+ (d) => {
+ if (d !== null && d !== undefined) lastFreshKey.value = fetchKey.value;
+ },
+ { immediate: true },
+);
const dataIsFresh = computed(() => lastFreshKey.value === fetchKey.value);
const resultsById = computed(() => {
@@ -452,6 +458,95 @@ 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.
+const router = useRouter();
+const layerTracesEnabled = computed<boolean>(() => {
+ if (layer.value?.caps?.traces !== true) return false;
+ const src = layer.value.traces?.source ?? 'native';
+ return src === 'native' || src === 'both';
+});
+function traceDrillMode(w: DashboardWidget): 'latency' | 'error' | null {
+ if (w.type !== 'line' || !layerTracesEnabled.value) return 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.
+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;
+ point: { x: number; y: number };
+ query: Record<string, string>;
+ title: string;
+ meta: string;
+ label: string;
+} | null>(null);
+function onDrillPoint(
+ w: DashboardWidget,
+ p: { seriesIndex: number; dataIndex: number; value: number; seriesName:
string; x: number; y: number },
+): void {
+ 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 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}`,
+ };
+ 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;
+ const band = p.seriesName ? `${p.seriesName} · ` : '';
+ drill.value = {
+ widgetId: w.id,
+ point: { x: p.x, y: p.y },
+ query,
+ title: w.title,
+ meta:
+ band +
+ t('around {t}', { t: bucketTimeLabel(timeRange.step, center) }) +
+ (mode === 'latency' ? ` · ≥ ${ms} ms` : ''),
+ label: mode === 'latency' ? t('View slow traces') : t('View error traces'),
+ };
+}
+function openDrill(): void {
+ const d = drill.value;
+ if (!d) return;
+ drill.value = null;
+ const href = router.resolve({ path: `/layer/${layerKey.value}/trace`, query:
d.query }).href;
+ window.open(href, '_blank', 'noopener');
+}
+function closeDrill(): void {
+ drill.value = null;
+}
+
// Pop-out wiring for top / record widgets. The TopList renders inside the
// widget body; its pop-out trigger lives in the widget's title bar (so it
// can't overlap the in-widget tab row). We hold a per-widget ref to the
@@ -661,7 +756,7 @@ const tabHostCtx = computed<TabHostCtx>(() => ({
<!-- Tile grid keeps its normal layout in compare mode; each widget
renders all N entities inline (card rows / overlaid lines /
per-entity tabs). -->
- <div class="grid">
+ <div ref="gridEl" class="grid">
<LayerWidgetTile
v-for="w in widgets.filter((wi) => !isHidden(wi.id))"
:key="w.id"
@@ -690,10 +785,34 @@ const tabHostCtx = computed<TabHostCtx>(() => ({
:has-multi-top-data="hasMultiTopData"
:compare-table-rows="compareTableRows"
:compare-table-entities="compareTableEntities"
+ :trace-drill-mode="traceDrillMode"
+ :on-drill-point="onDrillPoint"
+ :drill-open-id="drill?.widgetId ?? null"
@switch-tab="setActiveTab(w.id, $event)"
/>
</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"
+ :point="drill?.point ?? null"
+ :width="240"
+ @close="closeDrill"
+ >
+ <div v-if="drill" class="drill-pop">
+ <div class="drill-meta">
+ <span class="drill-title">{{ drill.title }}</span>
+ <span class="drill-sub">{{ drill.meta }}</span>
+ </div>
+ <button type="button" class="drill-link" @click="openDrill">
+ {{ drill.label }} <span class="arr" aria-hidden="true">→</span>
+ </button>
+ </div>
+ </FloatingPanel>
</div>
</template>
@@ -881,4 +1000,54 @@ const tabHostCtx = computed<TabHostCtx>(() => ({
grid-template-columns: repeat(12, 1fr);
}
}
+
+/* Metric→trace drill hint (inside the teleported FloatingPanel). */
+.drill-pop {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px 12px;
+}
+.drill-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 0;
+}
+.drill-title {
+ font-size: 11.5px;
+ font-weight: 600;
+ color: var(--sw-fg-0);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.drill-sub {
+ font-size: 10.5px;
+ color: var(--sw-fg-3);
+ font-variant-numeric: tabular-nums;
+}
+.drill-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ align-self: flex-start;
+ padding: 5px 10px;
+ background: var(--sw-bg-2);
+ border: 1px solid var(--sw-line-2);
+ border-radius: 5px;
+ color: var(--sw-accent);
+ font-size: 11.5px;
+ font-weight: 600;
+ cursor: pointer;
+ transition: border-color 0.12s, background 0.12s;
+}
+.drill-link:hover {
+ background: var(--sw-bg-3);
+ border-color: var(--sw-accent);
+}
+.drill-link .arr {
+ font-size: 12px;
+ line-height: 1;
+}
</style>
diff --git a/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
b/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
index 75c62af..0a6809a 100644
--- a/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerWidgetTile.vue
@@ -27,6 +27,7 @@
import { useI18n } from 'vue-i18n';
import type { DashboardWidget, DashboardWidgetResult } from
'@skywalking-horizon-ui/api-client';
import TimeChart from '@/components/charts/TimeChart.vue';
+import Icon from '@/components/icons/Icon.vue';
import TopList from '@/components/charts/TopList.vue';
import RecordList from '@/render/widgets/RecordList.vue';
import WidgetTip from '@/components/primitives/WidgetTip.vue';
@@ -71,6 +72,17 @@ 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;
entityLabel: (key: string) => string;
@@ -132,6 +144,17 @@ function chipRows(w: DashboardWidget, res:
DashboardWidgetResult | undefined): C
<WidgetTip :tip="widget.tip" />
</div>
<div class="w-head-right">
+ <span
+ v-if="!compareMode && traceDrillMode(widget)"
+ class="drill-flag"
+ :class="`df-${traceDrillMode(widget)}`"
+ :title="traceDrillMode(widget) === 'latency'
+ ? t('Click a point to view the slowest traces at that time')
+ : t('Click a point to view error traces at that time')"
+ >
+ <Icon name="trace" :size="11" />
+ <span>{{ t('traces') }}</span>
+ </span>
<!-- Card widgets render the unit beneath the big value;
surfacing it here too is a duplicate. Other types
(line / top / record) need the unit hint here because
@@ -190,6 +213,9 @@ function chipRows(w: DashboardWidget, res:
DashboardWidgetResult | undefined): C
:accent="widgetColor(widget)"
:format="widget.format"
:x-labels="xLabelsForLen(compareMode ? lineLen(widget.id) :
(result(widget.id)!.series![0]?.data.length ?? 0))"
+ :clickable="!compareMode && !!traceDrillMode(widget)"
+ :tip-suppressed="drillOpenId === widget.id"
+ @point-click="onDrillPoint(widget, $event)"
/>
<span v-else class="muted">{{ (compareMode ? compareLoading :
(isFetching && !results.has(widget.id))) ? t('loading…') : t('no data')
}}</span>
</template>
@@ -358,6 +384,30 @@ 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;
+ gap: 3px;
+ padding: 1px 6px 1px 4px;
+ border-radius: 999px;
+ font-size: 9.5px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ border: 1px solid transparent;
+}
+.drill-flag.df-latency {
+ color: var(--sw-warn);
+ background: var(--sw-warn-soft);
+ border-color: color-mix(in srgb, var(--sw-warn) 30%, transparent);
+}
+.drill-flag.df-error {
+ color: var(--sw-err);
+ background: var(--sw-err-soft);
+ border-color: color-mix(in srgb, var(--sw-err) 30%, transparent);
+}
.w-body {
/* Column-flex so charts / lists / records can flex: 1 and claim the
* full widget height. Card-type widgets opt into centering via the
diff --git a/packages/api-client/src/dashboard.ts
b/packages/api-client/src/dashboard.ts
index 26b1613..181e416 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -88,6 +88,30 @@ export type VisibleWhen =
| { kind: 'entity'; attribute: string; op: 'exists' }
| { 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).
+ */
+export type TraceDrill = { mode: 'off' | 'latency' | 'error' };
+
/**
* Per-entity dashboard scope. Each layer carries an independent widget
* set per scope; the SPA picks the right set based on the active
@@ -221,13 +245,6 @@ export interface DashboardWidget {
* BFF-side; hidden widgets come back flagged `hidden: true`.
*/
visibleWhen?: VisibleWhen;
- /**
- * When true, the BFF runs this widget's MQE against the whole layer
- * rather than scoping it to the currently-selected service. Used for
- * cross-service rollups (e.g. "Top 20 endpoints by traffic across the
- * layer"). MQE entity flips to `{ scope: All }`.
- */
- layerScope?: boolean;
/** Cap on label rows kept per entity in a labeled table widget under
* multi-entity compare; the remainder fold into one `(others)` row.
* Defaults to 8. */
@@ -238,6 +255,12 @@ 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.
+ */
+ traceDrill?: TraceDrill;
/** Legacy 24-col grid coordinates — kept for back-compat during the
* span-based flow-layout migration. New widgets should leave these
* unset and use `span` / `rowSpan` instead. */