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>

Reply via email to