This is an automated email from the ASF dual-hosted git repository.
wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git
The following commit(s) were added to refs/heads/main by this push:
new 6bef69c landing: sparkline series + tiny inline-SVG renderer
6bef69c is described below
commit 6bef69c424a5f92c1d589fc917496092e01d8f6d
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 15:37:04 2026 +0800
landing: sparkline series + tiny inline-SVG renderer
BFF fires a second batched MQE query for the configured spark metric,
but only over the topN-sliced rows — saves bandwidth versus widening
the first call. The series ride on each row as `spark` (number-or-null
array, one entry per MINUTE bucket).
UI side: new components/charts/Sparkline.vue. Hand-rolled SVG since the
charts/* ECharts wrapper is overkill for a 14×56 trend stripe. Handles
gap rendering via null entries, falls back to em-dash when fewer than
two finite samples are present, and colors itself with the layer dot
color so a Mesh card's sparkline reads as Mesh-blue.
---
apps/bff/src/oap/landing-routes.ts | 58 +++++++-
apps/ui/src/components/charts/Sparkline.vue | 171 ++++++++++++++++++++++++
apps/ui/src/views/overview/LayerLandingCard.vue | 16 ++-
3 files changed, 240 insertions(+), 5 deletions(-)
diff --git a/apps/bff/src/oap/landing-routes.ts
b/apps/bff/src/oap/landing-routes.ts
index c1e3505..8a85df1 100644
--- a/apps/bff/src/oap/landing-routes.ts
+++ b/apps/bff/src/oap/landing-routes.ts
@@ -41,7 +41,7 @@ import type { ConfigSource } from '../config/loader.js';
import type { SessionStore } from '../auth/sessions.js';
import { requireAuth } from '../auth/middleware.js';
import { graphqlPost } from './graphql-client.js';
-import { resolveColumnExpressions } from './mqe-catalog.js';
+import { expressionForServiceMetric, resolveColumnExpressions } from
'./mqe-catalog.js';
export interface LandingRouteDeps {
config: ConfigSource;
@@ -138,6 +138,22 @@ function alias(serviceIdx: number, columnIdx: number):
string {
* booster-ui does on its KPI tiles when an expression isn't wrapped in
* `avg(...)` already.
*/
+/**
+ * Convert a TIME_SERIES_VALUES MQE result into an ordered series, one
+ * bucket per `step` slot. Non-numeric / null values become `null` so
+ * the SPA can render a gap in the sparkline.
+ */
+function collapseToSeries(r: MqeResultShape | undefined): Array<number | null>
| null {
+ if (!r || r.error) return null;
+ const values = r.results?.[0]?.values ?? [];
+ if (values.length === 0) return null;
+ return values.map((v) => {
+ if (v.value === null || v.value === undefined) return null;
+ const n = Number(v.value);
+ return Number.isFinite(n) ? n : null;
+ });
+}
+
function collapseToScalar(r: MqeResultShape | undefined): number | null {
if (!r || r.error) return null;
const values = r.results?.[0]?.values ?? [];
@@ -176,6 +192,7 @@ export function registerLandingRoute(app: FastifyInstance,
deps: LandingRouteDep
label: labels[i] || metric,
...(units[i] ? { unit: units[i] } : {}),
}));
+ const sparkMetric = q.spark && q.spark.length > 0 ? q.spark : null;
// OAP enum is upper-case (`GENERAL`, `VIRTUAL_MQ`, …); the SPA
// sends lower-case route keys.
@@ -294,6 +311,43 @@ export function registerLandingRoute(app: FastifyInstance,
deps: LandingRouteDep
return bv - av;
});
+ const topRows = rows.slice(0, topN);
+
+ // Step 5 — sparkline series for the surviving topN only.
+ // Skipped when no spark metric was requested or its expression
+ // can't be resolved for this layer.
+ const sparkExpr = sparkMetric
+ ? expressionForServiceMetric(sparkMetric, layerKey)
+ : null;
+ if (sparkExpr && topRows.length > 0) {
+ const sparkFragments: string[] = [];
+ const sparkAliasFor = (i: number) => `s${i}`;
+ topRows.forEach((row, i) => {
+ const svc = sampled.find((s) => s.id === row.serviceId);
+ if (!svc) return;
+ const isNormal = svc.normal === false ? 'false' : 'true';
+ sparkFragments.push(
+ `${sparkAliasFor(i)}: execExpression(\n` +
+ ` expression: ${JSON.stringify(sparkExpr)},\n` +
+ ` entity: { scope: Service, serviceName:
${JSON.stringify(svc.value)}, normal: ${isNormal} },\n` +
+ ` duration: { start: ${JSON.stringify(window.start)}, end:
${JSON.stringify(window.end)}, step: MINUTE }\n` +
+ ` ) { type error results { values { value } } }`,
+ );
+ });
+ if (sparkFragments.length > 0) {
+ const sparkQuery = `query LandingSpark { ${sparkFragments.join('\n
')} }`;
+ try {
+ const sparkData = await graphqlPost<Record<string,
MqeResultShape>>(opts, sparkQuery);
+ topRows.forEach((row, i) => {
+ const series = collapseToSeries(sparkData[sparkAliasFor(i)]);
+ if (series) row.spark = series;
+ });
+ } catch {
+ // Soft-fail: card renders without sparkline column data.
+ }
+ }
+ }
+
const body: LandingResponse = {
layer: layerKey,
topN,
@@ -302,7 +356,7 @@ export function registerLandingRoute(app: FastifyInstance,
deps: LandingRouteDep
step: 'MINUTE',
durationStart: window.start,
durationEnd: window.end,
- rows: rows.slice(0, topN),
+ rows: topRows,
reachable: true,
};
return reply.send(body);
diff --git a/apps/ui/src/components/charts/Sparkline.vue
b/apps/ui/src/components/charts/Sparkline.vue
new file mode 100644
index 0000000..2affa81
--- /dev/null
+++ b/apps/ui/src/components/charts/Sparkline.vue
@@ -0,0 +1,171 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<!--
+ Tiny inline-SVG sparkline. Designed for the per-row sparkline column on
+ Overview landing cards — no ECharts dependency, no animation, no
+ interactivity. The full charts/* set wraps ECharts; this one is small
+ enough to skip the wrapper.
+
+ `null` entries in `values` are rendered as gaps. When fewer than two
+ finite samples are present, falls back to a single muted dot so the
+ column visually communicates "data present but not enough to draw".
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+
+const props = withDefaults(
+ defineProps<{
+ values: Array<number | null>;
+ width?: number;
+ height?: number;
+ color?: string;
+ /** Stroke width in px. */
+ stroke?: number;
+ }>(),
+ {
+ width: 56,
+ height: 14,
+ color: 'var(--sw-accent)',
+ stroke: 1.25,
+ },
+);
+
+interface PlotState {
+ d: string;
+ fillD: string;
+ dotX: number | null;
+ dotY: number | null;
+ empty: boolean;
+}
+
+const plot = computed<PlotState>(() => {
+ const n = props.values.length;
+ if (n < 2) {
+ return { d: '', fillD: '', dotX: null, dotY: null, empty: true };
+ }
+ let min = Infinity;
+ let max = -Infinity;
+ let finiteCount = 0;
+ for (const v of props.values) {
+ if (v === null || !Number.isFinite(v)) continue;
+ finiteCount++;
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ if (finiteCount < 2) {
+ return { d: '', fillD: '', dotX: null, dotY: null, empty: true };
+ }
+ const range = max - min || 1;
+ // Inset a half-pixel so strokes don't get clipped by the SVG edge.
+ const padY = props.stroke;
+ const w = props.width;
+ const h = props.height;
+ const xStep = (w - 1) / (n - 1);
+
+ const points: Array<{ x: number; y: number } | null> = props.values.map((v,
i) => {
+ if (v === null || !Number.isFinite(v)) return null;
+ const norm = (v - min) / range;
+ const x = 0.5 + i * xStep;
+ const y = h - padY - norm * (h - padY * 2);
+ return { x, y };
+ });
+
+ // Build the line path, breaking on null gaps. The fill area path
+ // shadows the line and closes to the baseline.
+ const dParts: string[] = [];
+ const fillParts: string[] = [];
+ let starting = true;
+ let lastFinite: { x: number; y: number } | null = null;
+ let segStart: { x: number; y: number } | null = null;
+ for (const p of points) {
+ if (!p) {
+ // Close out any in-flight fill segment.
+ if (segStart && lastFinite) {
+ fillParts.push(`L ${lastFinite.x.toFixed(2)} ${(h - 0.5).toFixed(2)} L
${segStart.x.toFixed(2)} ${(h - 0.5).toFixed(2)} Z`);
+ }
+ starting = true;
+ segStart = null;
+ continue;
+ }
+ if (starting) {
+ dParts.push(`M ${p.x.toFixed(2)} ${p.y.toFixed(2)}`);
+ fillParts.push(`M ${p.x.toFixed(2)} ${(h - 0.5).toFixed(2)} L
${p.x.toFixed(2)} ${p.y.toFixed(2)}`);
+ segStart = p;
+ starting = false;
+ } else {
+ dParts.push(`L ${p.x.toFixed(2)} ${p.y.toFixed(2)}`);
+ fillParts.push(`L ${p.x.toFixed(2)} ${p.y.toFixed(2)}`);
+ }
+ lastFinite = p;
+ }
+ if (segStart && lastFinite) {
+ fillParts.push(`L ${lastFinite.x.toFixed(2)} ${(h - 0.5).toFixed(2)} L
${segStart.x.toFixed(2)} ${(h - 0.5).toFixed(2)} Z`);
+ }
+
+ return {
+ d: dParts.join(' '),
+ fillD: fillParts.join(' '),
+ dotX: lastFinite?.x ?? null,
+ dotY: lastFinite?.y ?? null,
+ empty: false,
+ };
+});
+</script>
+
+<template>
+ <svg
+ v-if="!plot.empty"
+ class="sparkline"
+ :width="width"
+ :height="height"
+ :viewBox="`0 0 ${width} ${height}`"
+ role="img"
+ aria-label="trend"
+ >
+ <path :d="plot.fillD" :fill="color" fill-opacity="0.12" stroke="none" />
+ <path
+ :d="plot.d"
+ fill="none"
+ :stroke="color"
+ :stroke-width="stroke"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ <circle
+ v-if="plot.dotX !== null && plot.dotY !== null"
+ :cx="plot.dotX"
+ :cy="plot.dotY"
+ :r="stroke + 0.5"
+ :fill="color"
+ />
+ </svg>
+ <span v-else class="sparkline-empty" :style="{ width: `${width}px`, height:
`${height}px` }">—</span>
+</template>
+
+<style scoped>
+.sparkline {
+ display: inline-block;
+ vertical-align: middle;
+}
+.sparkline-empty {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--sw-fg-3);
+ font-size: 10px;
+}
+</style>
diff --git a/apps/ui/src/views/overview/LayerLandingCard.vue
b/apps/ui/src/views/overview/LayerLandingCard.vue
index 8ddad5d..73d2696 100644
--- a/apps/ui/src/views/overview/LayerLandingCard.vue
+++ b/apps/ui/src/views/overview/LayerLandingCard.vue
@@ -19,6 +19,7 @@ import { computed, toRef } from 'vue';
import { RouterLink } from 'vue-router';
import type { LayerDef } from '@skywalking-horizon-ui/api-client';
import Icon from '@/components/icons/Icon.vue';
+import Sparkline from '@/components/charts/Sparkline.vue';
import { metricMeta } from '@/composables/metricCatalog';
import { useLayerLanding } from '@/composables/useLayerLanding';
import { useSetupStore } from '@/stores/setup';
@@ -99,7 +100,16 @@ const serviceHref = (serviceId: string): string =>
>
{{ fmtMetric(row.metrics[c.metric]) }}
</td>
- <td v-if="cfg.landing.spark" class="spark-col muted">—</td>
+ <td v-if="cfg.landing.spark" class="spark-col">
+ <Sparkline
+ v-if="row.spark && row.spark.length > 1"
+ :values="row.spark"
+ :width="60"
+ :height="cfg.landing.spark.height ?? 14"
+ :color="layer.color"
+ />
+ <span v-else class="empty-cell">—</span>
+ </td>
</tr>
<tr
v-for="i in placeholderCount"
@@ -130,8 +140,8 @@ const serviceHref = (serviceId: string): string =>
<RouterLink to="/setup">customize this card</RouterLink>.
</span>
<span v-else class="placeholder-note">
- Sparkline + live trend land in Stage 2.6b —
- <RouterLink to="/setup">customize this card</RouterLink>.
+ <RouterLink to="/setup">Customize this card</RouterLink> ·
+ <RouterLink :to="detailHref">all services →</RouterLink>
</span>
</div>
</div>