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

wu-sheng pushed a commit to branch feat/metric-trace-drilldown
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 9ae8a9c3c0c716c93d33ec6e7d9c495d5768b799
Author: Wu Sheng <[email protected]>
AuthorDate: Thu Jul 2 07:48:24 2026 +0800

    feat(traces): drill from General-service metric charts to native traces
    
    Click a datapoint on a drill-capable General service line widget — Avg
    Response Time, Response Time Percentile, Error Rate, Apdex, Success Rate,
    MQ consume latency, across Service / Instance / Endpoint — to open 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 open slowest-first with a minimum trace
    duration of the clicked value; error / success-rate widgets open with the
    trace status set to Error. A "traces" flag in the widget header marks the
    eligible charts.
    
    Opt-in per line widget via a new optional `traceDrill` template field
    (mode: latency | error), authored in the widget editor next to the MQE
    and enabled only on a layer whose native Traces component is on. The
    bundled General service layer stamps the ten eligible widgets.
    
    Also:
    - fix: the dashboard "Reading data…" gate hung on Back-from-Traces — a
      warm vue-query cache repopulates synchronously on remount with no change
      event, so the freshness watch now runs immediately at mount.
    - fix: the drill popover no longer stacks under the chart hover tooltip
      (the clicked chart's tooltip is suppressed while its popover is pinned).
    - remove: the deprecated `layerScope` widget option. It emitted OAP's
      `scope: All` entity (deprecated in the query-protocol since 9.4.0) and
      no bundled dashboard used it; legacy stored dashboards degrade to normal
      service scope.
---
 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 | 180 ++++++++++++++++++++-
 .../src/render/layer-dashboard/LayerWidgetTile.vue |  50 ++++++
 packages/api-client/src/dashboard.ts               |  35 +++-
 20 files changed, 479 insertions(+), 76 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90ae914..d725cec 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..a7ef0d3 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 can only open a trace list when THIS layer's native Traces
+// component is on (source native/both). When off, the control is disabled with
+// a notice — a drill would have nowhere to land.
+const layerHasNativeTraces = 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="!layerHasNativeTraces"
+                      @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="!layerHasNativeTraces" class="d-hint drill-off">
+                  Disabled — enable this layer's native <b>Traces</b> 
component to use metric→trace drill-down; without it a click would have no 
trace list to open.
+                </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..db84a1a 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,92 @@ function widgetColor(w: { id?: string; title?: string; 
expressions?: string[] })
   return colorForMetric(w.id || w.title || w.expressions?.[0] || '');
 }
 
+// Metric→trace drill: a `line` widget opts in via `traceDrill` (native-trace
+// layers only); the declared `mode` maps a click to a pre-filtered Traces tab.
+const router = useRouter();
+const layerHasNativeTraces = computed<boolean>(() => {
+  const src = layer.value?.traces?.source ?? 'native';
+  return src === 'native' || src === 'both';
+});
+function traceDrillMode(w: DashboardWidget): 'latency' | 'error' | null {
+  if (w.type !== 'line' || !layerHasNativeTraces.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 +753,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 +782,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 +997,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..1874e8d 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -88,6 +88,28 @@ export type VisibleWhen =
   | { kind: 'entity'; attribute: string; op: 'exists' }
   | { kind: 'entity'; attribute: string; op: 'eq'; value: string };
 
+/**
+ * Opt a `line` widget into metric→native-trace drill-down. When set (and
+ * the layer exposes a native `trace` scope), clicking a datapoint on the
+ * chart offers a link into the pre-filtered Traces tab, centered on the
+ * clicked bucket and scoped to the active service / instance / endpoint.
+ *
+ * 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 +243,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 +253,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. */

Reply via email to