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. */
