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 36b2026  dashboards: real per-layer widget grid driven by booster-ui 
templates
36b2026 is described below

commit 36b20265f781208672d772117e8a157cd8d62512
Author: Wu Sheng <[email protected]>
AuthorDate: Tue May 12 16:35:07 2026 +0800

    dashboards: real per-layer widget grid driven by booster-ui templates
    
    Phase 3 first cut. Each layer's Dashboards tab now renders a real
    widget grid bound to live MQE data — no more 'Coming in Phase 3'
    placeholder.
    
    BFF side:
    - New /api/layer/:key/dashboard/config (GET) returns the default
      widget set for a layer. Widget definitions are lifted from
      booster-ui's templates (service_apdex, service_sla, service_cpm,
      service_resp_time, service_percentile{p='50..99'}) so the metric
      coverage matches what operators expect.
    - New /api/layer/:key/dashboard (POST) batches every widget's MQE
      expressions into one GraphQL trip, scopes them to a chosen service
      (auto-picks the first one when omitted), and returns scalars for
      Card widgets + time series for Line widgets.
    
    UI side:
    - New TimeChart.vue wraps ECharts for multi-series line plots — the
      one place ECharts is touched, per project convention. Theme matches
      the dark tokens.
    - LayerDashboardsView renders the 24-col grid from the widget config.
      Card widgets show a big scalar + unit; line widgets render the chart
      at a height proportional to the booster grid 'h' value so multi-line
      percentile charts stay readable.
    - useLayerDashboard composable shares vue-query cache with the rest of
      the per-layer page; switching the selected service via the top
      selector triggers a re-fetch automatically.
    
    Dashboards drops out of the placeholder-tab list; instances /
    endpoints / topology / dependency / traces / logs / profiling stay
    service-aware placeholders for now.
---
 apps/bff/src/dashboard/defaults.ts              | 192 ++++++++++++++++
 apps/bff/src/dashboard/routes.ts                | 270 +++++++++++++++++++++++
 apps/bff/src/server.ts                          |   2 +
 apps/ui/src/api/client.ts                       |  25 +++
 apps/ui/src/components/charts/TimeChart.vue     | 165 ++++++++++++++
 apps/ui/src/composables/useLayerDashboard.ts    |  66 ++++++
 apps/ui/src/router/index.ts                     |   5 +-
 apps/ui/src/views/layer/LayerDashboardsView.vue | 279 ++++++++++++++++++++++++
 packages/api-client/src/dashboard.ts            |  86 ++++++++
 packages/api-client/src/index.ts                |   8 +
 10 files changed, 1097 insertions(+), 1 deletion(-)

diff --git a/apps/bff/src/dashboard/defaults.ts 
b/apps/bff/src/dashboard/defaults.ts
new file mode 100644
index 0000000..3945896
--- /dev/null
+++ b/apps/bff/src/dashboard/defaults.ts
@@ -0,0 +1,192 @@
+/*
+ * 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.
+ */
+
+/**
+ * Default dashboard widget sets per OAP layer enum. These are lifted
+ * verbatim from the booster-ui templates the operator already knows —
+ * see `docs/design/research/booster-templates/<layer>/<layer>-service.json`
+ * for the source rows. Phase 7 admin will let operators edit + persist
+ * their own set; until then the BFF serves these defaults.
+ *
+ * 24-column grid: matches booster-ui's vue-grid-layout dimensions so
+ * positions and spans port without rework.
+ */
+
+import type { DashboardWidget } from '@skywalking-horizon-ui/api-client';
+
+/** Service-scope service-shaped layers (general / mesh / k8s_service). */
+const SERVICE_WIDGETS: DashboardWidget[] = [
+  {
+    id: 'apdex',
+    title: 'Service Apdex',
+    tip: 'User satisfaction score on a 0–1 scale. service_apdex is 
integer-times-10000 server-side; expression divides to bring it into a familiar 
0–1 range.',
+    type: 'card',
+    expressions: ['avg(service_apdex)/10000'],
+    x: 0, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'sla',
+    title: 'Success Rate',
+    tip: 'Percentage of successful requests. Source field is 
integer-times-100.',
+    type: 'card',
+    unit: '%',
+    expressions: ['avg(service_sla)/100'],
+    x: 8, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'cpm',
+    title: 'Service Load',
+    tip: 'Calls per minute. For HTTP / gRPC / RPC services the value reflects 
request throughput.',
+    type: 'card',
+    unit: 'calls / min',
+    expressions: ['avg(service_cpm)'],
+    x: 16, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'resp_time',
+    title: 'Service Avg Response Time',
+    tip: 'Mean latency across all calls in the window.',
+    type: 'line',
+    unit: 'ms',
+    expressions: ['service_resp_time'],
+    x: 0, y: 5, w: 12, h: 14,
+  },
+  {
+    id: 'percentile',
+    title: 'Service Response Time Percentile',
+    tip: 'Latency at p50 / p75 / p90 / p95 / p99 — useful for tail behavior.',
+    type: 'line',
+    unit: 'ms',
+    expressions: [
+      "service_percentile{p='50'}",
+      "service_percentile{p='75'}",
+      "service_percentile{p='90'}",
+      "service_percentile{p='95'}",
+      "service_percentile{p='99'}",
+    ],
+    x: 12, y: 5, w: 12, h: 14,
+  },
+  {
+    id: 'cpm_line',
+    title: 'Service Load (line)',
+    type: 'line',
+    unit: 'calls / min',
+    expressions: ['service_cpm'],
+    x: 0, y: 19, w: 12, h: 12,
+  },
+  {
+    id: 'sla_line',
+    title: 'Success Rate (line)',
+    type: 'line',
+    unit: '%',
+    expressions: ['service_sla/100'],
+    x: 12, y: 19, w: 12, h: 12,
+  },
+];
+
+/** Browser-layer service-scope widgets (`browser` enum). Pulled from
+ *  booster-templates/browser/browser-app.json. */
+const BROWSER_WIDGETS: DashboardWidget[] = [
+  {
+    id: 'app_pv',
+    title: 'Page Views',
+    type: 'card',
+    expressions: ['avg(browser_app_pv)'],
+    x: 0, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'app_error_rate',
+    title: 'Page Error Rate',
+    type: 'card',
+    unit: '%',
+    expressions: ['avg(browser_app_page_error_rate)/100'],
+    x: 8, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'app_error_sum',
+    title: 'Page Errors',
+    type: 'card',
+    expressions: ['avg(browser_app_error_sum)'],
+    x: 16, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'app_pv_line',
+    title: 'Page Views (over time)',
+    type: 'line',
+    expressions: ['browser_app_pv'],
+    x: 0, y: 5, w: 12, h: 12,
+  },
+  {
+    id: 'app_error_line',
+    title: 'Page Errors (over time)',
+    type: 'line',
+    expressions: ['browser_app_error_sum'],
+    x: 12, y: 5, w: 12, h: 12,
+  },
+];
+
+/** Generic fallback widget set for layers without a dedicated template.
+ *  Mirrors the General-Service KPI block since most virtual / database
+ *  / MQ layers also expose service_cpm + service_resp_time + service_sla. */
+const GENERIC_WIDGETS: DashboardWidget[] = [
+  {
+    id: 'cpm',
+    title: 'Calls per minute',
+    type: 'card',
+    unit: 'calls / min',
+    expressions: ['avg(service_cpm)'],
+    x: 0, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'sla',
+    title: 'Success Rate',
+    type: 'card',
+    unit: '%',
+    expressions: ['avg(service_sla)/100'],
+    x: 8, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'resp',
+    title: 'Avg Response Time',
+    type: 'card',
+    unit: 'ms',
+    expressions: ['avg(service_resp_time)'],
+    x: 16, y: 0, w: 8, h: 5,
+  },
+  {
+    id: 'cpm_line',
+    title: 'Calls per minute (line)',
+    type: 'line',
+    unit: 'calls / min',
+    expressions: ['service_cpm'],
+    x: 0, y: 5, w: 24, h: 12,
+  },
+];
+
+/**
+ * Resolve the default widget set for `(layerKey)`. The set is per
+ * layer enum, but we also map alias keys (CACHE → VIRTUAL_CACHE etc.)
+ * to the modern enum since older OAP builds still emit aliases.
+ */
+export function defaultWidgetsFor(layerKey: string): DashboardWidget[] {
+  const k = layerKey.toUpperCase();
+  if (k === 'GENERAL' || k === 'MESH' || k === 'MESH_CP' || k === 'MESH_DP' || 
k === 'K8S_SERVICE') {
+    return SERVICE_WIDGETS;
+  }
+  if (k === 'BROWSER') return BROWSER_WIDGETS;
+  return GENERIC_WIDGETS;
+}
diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts
new file mode 100644
index 0000000..8a00fc0
--- /dev/null
+++ b/apps/bff/src/dashboard/routes.ts
@@ -0,0 +1,270 @@
+/*
+ * 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.
+ */
+
+/**
+ * `POST /api/layer/:key/dashboard` — runs each widget's MQE expression
+ * against the OAP server and returns the result keyed by widget id.
+ *
+ * Body shape: `{ service?: string, widgets?: DashboardWidget[] }`. When
+ * `widgets` is omitted, the BFF substitutes the layer's built-in
+ * default set (see `defaults.ts`). When `service` is omitted, the BFF
+ * picks the first service from `listServices(layer)` so the response
+ * is never empty — UIs can pass an explicit service to scope.
+ *
+ * Each widget's expressions are batched into one GraphQL query via
+ * aliases — same pattern as the landing route. Card widgets collapse
+ * to a scalar (avg across the time-series window); line widgets keep
+ * the full series per expression.
+ */
+
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import type {
+  DashboardResponse,
+  DashboardWidget,
+  DashboardWidgetResult,
+  FetchLike,
+} from '@skywalking-horizon-ui/api-client';
+import type { ConfigSource } from '../config/loader.js';
+import type { SessionStore } from '../auth/sessions.js';
+import { requireAuth } from '../auth/middleware.js';
+import { graphqlPost } from '../oap/graphql-client.js';
+import { defaultWidgetsFor } from './defaults.js';
+
+export interface DashboardRouteDeps {
+  config: ConfigSource;
+  sessions: SessionStore;
+  fetch?: FetchLike;
+}
+
+const widgetSchema = z.object({
+  id: z.string().min(1),
+  title: z.string(),
+  tip: z.string().optional(),
+  type: z.enum(['card', 'line']),
+  expressions: z.array(z.string().min(1)).min(1).max(8),
+  unit: z.string().optional(),
+  x: z.number().int().min(0),
+  y: z.number().int().min(0),
+  w: z.number().int().positive(),
+  h: z.number().int().positive(),
+});
+const bodySchema = z.object({
+  service: z.string().optional(),
+  widgets: z.array(widgetSchema).max(40).optional(),
+});
+
+interface MqeValuesShape {
+  values?: Array<{ id?: string | null; value?: string | null }>;
+}
+interface MqeResultShape {
+  type: string;
+  error?: string | null;
+  results?: MqeValuesShape[];
+}
+
+const LIST_FIRST_SERVICE = /* GraphQL */ `
+  query FirstService($layer: String!) {
+    services: listServices(layer: $layer) { id name normal }
+  }
+`;
+
+const DEFAULT_WINDOW_MIN = 15;
+
+interface Window {
+  start: string;
+  end: string;
+}
+function fmtMinute(d: Date): string {
+  const yyyy = d.getUTCFullYear();
+  const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
+  const dd = String(d.getUTCDate()).padStart(2, '0');
+  const hh = String(d.getUTCHours()).padStart(2, '0');
+  const mi = String(d.getUTCMinutes()).padStart(2, '0');
+  return `${yyyy}-${mm}-${dd} ${hh}${mi}`;
+}
+function defaultWindow(): Window {
+  const end = new Date();
+  end.setUTCSeconds(0, 0);
+  const start = new Date(end.getTime() - DEFAULT_WINDOW_MIN * 60_000);
+  return { start: fmtMinute(start), end: fmtMinute(end) };
+}
+
+function buildFragment(
+  alias: string,
+  expression: string,
+  serviceName: string,
+  normal: boolean,
+  w: Window,
+): string {
+  return (
+    `${alias}: execExpression(\n` +
+    `      expression: ${JSON.stringify(expression)},\n` +
+    `      entity: { scope: Service, serviceName: 
${JSON.stringify(serviceName)}, normal: ${normal ? 'true' : 'false'} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `    ) { type error results { values { value } } }`
+  );
+}
+
+function parseSeries(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 avgOf(series: Array<number | null> | null): number | null {
+  if (!series) return null;
+  const xs = series.filter((v): v is number => v !== null);
+  if (xs.length === 0) return null;
+  return xs.reduce((a, b) => a + b, 0) / xs.length;
+}
+
+export function registerDashboardRoute(app: FastifyInstance, deps: 
DashboardRouteDeps): void {
+  const auth = requireAuth(deps);
+  app.post(
+    '/api/layer/:key/dashboard',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+        return reply.code(400).send({ error: 'invalid_layer_key' });
+      }
+      const parsed = bodySchema.safeParse(req.body ?? {});
+      if (!parsed.success) {
+        return reply.code(400).send({ error: 'invalid_body', detail: 
parsed.error.flatten() });
+      }
+      const widgets: DashboardWidget[] = parsed.data.widgets ?? 
defaultWidgetsFor(layerKey);
+      let serviceName = parsed.data.service ?? '';
+      let normal = true;
+      const cfgCurrent = deps.config.current;
+      const opts = {
+        statusUrl: cfgCurrent.oap.statusUrl,
+        timeoutMs: cfgCurrent.oap.timeoutMs,
+        fetch: deps.fetch,
+      };
+      const window = defaultWindow();
+
+      const baseResp: DashboardResponse = {
+        layer: layerKey,
+        service: serviceName || null,
+        generatedAt: Date.now(),
+        step: 'MINUTE',
+        durationStart: window.start,
+        durationEnd: window.end,
+        widgets: [],
+        reachable: true,
+      };
+
+      // Step 1 — resolve service if not provided.
+      if (!serviceName) {
+        try {
+          const data = await graphqlPost<{ services: Array<{ id: string; name: 
string; normal: boolean }> }>(
+            opts,
+            LIST_FIRST_SERVICE,
+            { layer: layerKey.toUpperCase() },
+          );
+          const first = data.services?.[0];
+          if (first) {
+            serviceName = first.name;
+            normal = first.normal !== false;
+            baseResp.service = serviceName;
+          } else {
+            return reply.send({
+              ...baseResp,
+              widgets: widgets.map((w) => ({ id: w.id, error: 'no service in 
layer' })),
+            });
+          }
+        } catch (err) {
+          return reply.send({
+            ...baseResp,
+            reachable: false,
+            error: err instanceof Error ? err.message : String(err),
+            widgets: widgets.map((w) => ({ id: w.id, error: 'oap unreachable' 
})),
+          });
+        }
+      }
+
+      // Step 2 — batch all widget × expression queries into one GraphQL trip.
+      const fragments: string[] = [];
+      const aliasMap = new Map<string, { wIdx: number; eIdx: number }>();
+      widgets.forEach((widget, wIdx) => {
+        widget.expressions.forEach((expr, eIdx) => {
+          const alias = `w${wIdx}_e${eIdx}`;
+          aliasMap.set(alias, { wIdx, eIdx });
+          fragments.push(buildFragment(alias, expr, serviceName, normal, 
window));
+        });
+      });
+      let data: Record<string, MqeResultShape> = {};
+      if (fragments.length > 0) {
+        const query = `query DashboardMqe { ${fragments.join('\n    ')} }`;
+        try {
+          data = await graphqlPost<Record<string, MqeResultShape>>(opts, 
query);
+        } catch (err) {
+          return reply.send({
+            ...baseResp,
+            reachable: false,
+            error: err instanceof Error ? err.message : String(err),
+            widgets: widgets.map((w) => ({ id: w.id, error: 'mqe batch failed' 
})),
+          });
+        }
+      }
+
+      // Step 3 — collapse per widget.
+      const results: DashboardWidgetResult[] = widgets.map((widget, wIdx) => {
+        const series = widget.expressions.map((_, eIdx) => 
parseSeries(data[`w${wIdx}_e${eIdx}`]));
+        const allFailed = series.every((s) => s === null);
+        if (allFailed) {
+          return { id: widget.id, error: 'no data' };
+        }
+        if (widget.type === 'card') {
+          // Card collapses to scalar from the first non-null series.
+          const first = series.find((s) => s !== null) ?? null;
+          return { id: widget.id, value: avgOf(first) };
+        }
+        return {
+          id: widget.id,
+          series: series.map((s, eIdx) => ({
+            label: widget.expressions[eIdx],
+            data: s ?? [],
+          })),
+        };
+      });
+
+      return reply.send({ ...baseResp, widgets: results });
+    },
+  );
+
+  // GET version returns the default widget config without running queries —
+  // useful for the SPA to know what to render before invoking POST.
+  app.get(
+    '/api/layer/:key/dashboard/config',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const layerKey = params.key;
+      if (!layerKey || !/^[a-z0-9_]+$/i.test(layerKey)) {
+        return reply.code(400).send({ error: 'invalid_layer_key' });
+      }
+      return reply.send({ layer: layerKey, widgets: 
defaultWidgetsFor(layerKey) });
+    },
+  );
+}
diff --git a/apps/bff/src/server.ts b/apps/bff/src/server.ts
index f5b171f..06b3d8c 100644
--- a/apps/bff/src/server.ts
+++ b/apps/bff/src/server.ts
@@ -21,6 +21,7 @@ import { AuditLogger } from './audit/logger.js';
 import { registerAuthRoutes } from './auth/routes.js';
 import { SessionStore } from './auth/sessions.js';
 import { loadConfig, type ConfigSource } from './config/loader.js';
+import { registerDashboardRoute } from './dashboard/routes.js';
 import { registerOapInfoRoute } from './oap/info-routes.js';
 import { registerLandingRoute } from './oap/landing-routes.js';
 import { registerMenuRoute } from './oap/menu-routes.js';
@@ -63,6 +64,7 @@ registerAuthRoutes(app, source, sessions, audit);
 registerOapInfoRoute(app, { config: source, sessions });
 registerMenuRoute(app, { config: source, sessions });
 registerLandingRoute(app, { config: source, sessions });
+registerDashboardRoute(app, { config: source, sessions });
 registerSetupRoutes(app, { config: source, sessions, audit, store: setupStore 
});
 registerOapRoutes(app, { config: source, sessions, audit });
 registerPreflightRoutes(app, { config: source, sessions });
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index d84c055..d0bbd92 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -16,6 +16,9 @@
  */
 
 import type {
+  DashboardConfig,
+  DashboardResponse,
+  DashboardWidget,
   LandingConfig,
   LandingResponse,
   MenuResponse,
@@ -37,6 +40,10 @@ export type {
   LandingColumn,
   LandingResponse,
   LandingServiceRow,
+  DashboardConfig,
+  DashboardResponse,
+  DashboardWidget,
+  DashboardWidgetResult,
 } from '@skywalking-horizon-ui/api-client';
 
 
@@ -147,6 +154,24 @@ export class BffClient {
     );
   }
 
+  // ── dashboards (per-layer widget data) ───────────────────────────────
+  dashboardConfig(layerKey: string): Promise<DashboardConfig> {
+    return this.request<DashboardConfig>(
+      'GET',
+      `/api/layer/${encodeURIComponent(layerKey)}/dashboard/config`,
+    );
+  }
+  dashboard(
+    layerKey: string,
+    body: { service?: string; widgets?: DashboardWidget[] } = {},
+  ): Promise<DashboardResponse> {
+    return this.request<DashboardResponse>(
+      'POST',
+      `/api/layer/${encodeURIComponent(layerKey)}/dashboard`,
+      body,
+    );
+  }
+
   // ── cluster / preflight ──────────────────────────────────────────────
   preflight(): Promise<unknown> {
     return this.request('GET', '/api/preflight');
diff --git a/apps/ui/src/components/charts/TimeChart.vue 
b/apps/ui/src/components/charts/TimeChart.vue
new file mode 100644
index 0000000..b582f90
--- /dev/null
+++ b/apps/ui/src/components/charts/TimeChart.vue
@@ -0,0 +1,165 @@
+<!--
+  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.
+-->
+<!--
+  Thin ECharts wrapper for multi-series line charts. Used by the
+  per-layer Dashboards widgets. Owns its instance lifecycle and resizes
+  with the container — the parent gives us a fixed pixel height.
+
+  Per project convention this is the *only* place ECharts is touched —
+  no view component imports echarts directly. Swap-out point if we
+  decide to move away from ECharts later.
+-->
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import * as echarts from 'echarts/core';
+import { LineChart } from 'echarts/charts';
+import { GridComponent, LegendComponent, TooltipComponent } from 
'echarts/components';
+import { CanvasRenderer } from 'echarts/renderers';
+import type { EChartsType } from 'echarts/core';
+
+echarts.use([LineChart, GridComponent, LegendComponent, TooltipComponent, 
CanvasRenderer]);
+
+interface Series {
+  label: string;
+  data: Array<number | null>;
+}
+
+const props = withDefaults(
+  defineProps<{
+    series: Series[];
+    height?: number;
+    /** Optional unit suffix shown in the tooltip. */
+    unit?: string;
+    /** Color hint for the first series (subsequent series cycle through
+     *  a default palette so percentile lines remain distinguishable). */
+    accent?: string;
+  }>(),
+  {
+    height: 180,
+    accent: 'var(--sw-accent)',
+  },
+);
+
+const PALETTE = [
+  '#f97316', // sw-accent (orange)
+  '#60a5fa', // info-ish
+  '#a78bfa', // purple
+  '#22d3ee', // cyan
+  '#f472b6', // pink
+  '#34d399', // ok-ish
+];
+
+const container = ref<HTMLDivElement | null>(null);
+let chart: EChartsType | null = null;
+
+function buildOption(): echarts.EChartsCoreOption {
+  // Generate equal-spaced bucket indices for the x-axis. We don't have
+  // explicit timestamps from the BFF response (the duration window is
+  // implied to be MINUTE-stepped over the last 15m), so we label the
+  // axis with relative "-Nm" markers.
+  const length = props.series[0]?.data.length ?? 0;
+  const xLabels = Array.from({ length }, (_, i) => `-${length - i - 1}m`);
+  return {
+    backgroundColor: 'transparent',
+    tooltip: {
+      trigger: 'axis',
+      backgroundColor: 'rgba(20,20,24,0.92)',
+      borderColor: 'rgba(255,255,255,0.08)',
+      textStyle: { color: '#e5e7eb', fontSize: 11 },
+      valueFormatter: (v: unknown) =>
+        typeof v === 'number' && Number.isFinite(v)
+          ? `${v.toFixed(2)}${props.unit ? ` ${props.unit}` : ''}`
+          : '—',
+    },
+    legend: {
+      show: props.series.length > 1,
+      bottom: 0,
+      textStyle: { color: '#94a3b8', fontSize: 10 },
+      itemWidth: 10,
+      itemHeight: 8,
+      icon: 'roundRect',
+    },
+    grid: {
+      left: 36,
+      right: 8,
+      top: 8,
+      bottom: props.series.length > 1 ? 24 : 8,
+      containLabel: false,
+    },
+    xAxis: {
+      type: 'category',
+      data: xLabels,
+      axisLine: { lineStyle: { color: 'rgba(255,255,255,0.08)' } },
+      axisLabel: { color: '#64748b', fontSize: 9, interval: Math.floor(length 
/ 6) },
+      splitLine: { show: false },
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: { show: false },
+      axisLabel: { color: '#64748b', fontSize: 9 },
+      splitLine: { lineStyle: { color: 'rgba(255,255,255,0.06)' } },
+    },
+    series: props.series.map((s, i) => ({
+      name: s.label,
+      type: 'line',
+      smooth: true,
+      symbol: 'none',
+      lineStyle: { width: 1.5 },
+      data: s.data.map((v) => (v === null ? '-' : v)),
+      // Resolve CSS var for the first series; fall back to the palette.
+      itemStyle: { color: i === 0 ? PALETTE[0] : PALETTE[i % PALETTE.length] },
+      areaStyle:
+        props.series.length === 1
+          ? { color: PALETTE[0], opacity: 0.12 }
+          : undefined,
+    })),
+  };
+}
+
+onMounted(() => {
+  if (!container.value) return;
+  chart = echarts.init(container.value, null, { renderer: 'canvas' });
+  chart.setOption(buildOption());
+  const ro = new ResizeObserver(() => chart?.resize());
+  ro.observe(container.value);
+  onBeforeUnmount(() => {
+    ro.disconnect();
+    chart?.dispose();
+    chart = null;
+  });
+});
+
+watch(
+  () => props.series,
+  () => chart?.setOption(buildOption(), { replaceMerge: ['series'] }),
+  { deep: true },
+);
+watch(
+  () => props.unit,
+  () => chart?.setOption(buildOption()),
+);
+</script>
+
+<template>
+  <div ref="container" class="time-chart" :style="{ height: `${height}px` }" />
+</template>
+
+<style scoped>
+.time-chart {
+  width: 100%;
+}
+</style>
diff --git a/apps/ui/src/composables/useLayerDashboard.ts 
b/apps/ui/src/composables/useLayerDashboard.ts
new file mode 100644
index 0000000..d66f33b
--- /dev/null
+++ b/apps/ui/src/composables/useLayerDashboard.ts
@@ -0,0 +1,66 @@
+/*
+ * 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.
+ */
+
+/**
+ * Two-stage dashboard fetch:
+ *   1. `dashboardConfig(layerKey)` — pulls the default widget set from
+ *      the BFF (no MQE execution, cheap).
+ *   2. `dashboard(layerKey, { service })` — runs every widget's MQE in
+ *      one batched GraphQL trip and returns scalars + series.
+ *
+ * Config is per-layer, results are per-(layer, service). Both queries
+ * share the same vue-query cache so switching back to a previously
+ * viewed service is instant.
+ */
+
+import { computed, type Ref } from 'vue';
+import { useQuery } from '@tanstack/vue-query';
+import { bffClient } from '@/api/client';
+
+export function useLayerDashboardConfig(layerKey: Ref<string>) {
+  const q = useQuery({
+    queryKey: ['dashboard-config', layerKey],
+    queryFn: () => bffClient.dashboardConfig(layerKey.value),
+    enabled: computed(() => layerKey.value.length > 0),
+    staleTime: 5 * 60_000,
+  });
+  return {
+    config: computed(() => q.data.value ?? null),
+    isLoading: q.isLoading,
+    error: q.error,
+  };
+}
+
+export function useLayerDashboard(layerKey: Ref<string>, service: Ref<string | 
null>) {
+  const q = useQuery({
+    queryKey: ['dashboard', layerKey, service],
+    queryFn: () =>
+      bffClient.dashboard(layerKey.value, service.value ? { service: 
service.value } : {}),
+    enabled: computed(() => layerKey.value.length > 0),
+    staleTime: 45_000,
+    refetchInterval: 60_000,
+    refetchOnWindowFocus: true,
+    retry: 1,
+  });
+  return {
+    data: computed(() => q.data.value ?? null),
+    isLoading: q.isLoading,
+    isFetching: q.isFetching,
+    error: q.error,
+    refetch: q.refetch,
+  };
+}
diff --git a/apps/ui/src/router/index.ts b/apps/ui/src/router/index.ts
index bc113ab..77b7bb1 100644
--- a/apps/ui/src/router/index.ts
+++ b/apps/ui/src/router/index.ts
@@ -36,7 +36,6 @@ function layerRoute(): RouteRecordRaw {
     { path: 'endpoints', label: 'Endpoints', phase: 'Phase 2 / 3' },
     { path: 'topology', label: 'Topology', phase: 'Phase 4' },
     { path: 'dependency', label: 'API dependency', phase: 'Phase 4' },
-    { path: 'dashboards', label: 'Dashboards', phase: 'Phase 3' },
     { path: 'traces', label: 'Traces', phase: 'Phase 5' },
     { path: 'logs', label: 'Logs', phase: 'Phase 5' },
     { path: 'profiling', label: 'Profiling', phase: 'Phase 8' },
@@ -60,6 +59,10 @@ function layerRoute(): RouteRecordRaw {
           query: { service: String(to.params.serviceId) },
         }),
       },
+      // Real dashboards (Phase 3). Widget set comes from the BFF's
+      // defaults (lifted from booster-ui templates); MQE runs live via
+      // /api/layer/:key/dashboard.
+      { path: 'dashboards', component: () => 
import('@/views/layer/LayerDashboardsView.vue') },
       ...placeholderTabs.map<RouteRecordRaw>((f) => ({
         path: f.path,
         component: () => import('@/views/layer/LayerTabPlaceholder.vue'),
diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue 
b/apps/ui/src/views/layer/LayerDashboardsView.vue
new file mode 100644
index 0000000..ffdbc59
--- /dev/null
+++ b/apps/ui/src/views/layer/LayerDashboardsView.vue
@@ -0,0 +1,279 @@
+<!--
+  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.
+-->
+<!--
+  Per-layer Dashboards tab. Widget set + MQE expressions are seeded from
+  the BFF (lifted from booster-ui's templates); widget data comes from a
+  single POST /api/layer/:key/dashboard call scoped to the currently
+  selected service.
+
+  Cards (single-value KPIs) render with a big number + unit; line
+  widgets render with a TimeChart wrapping ECharts. Grid layout uses
+  24-column CSS grid coordinates matching booster-ui's vue-grid-layout
+  so position + span port straight from the upstream templates.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import { useRoute, RouterLink } from 'vue-router';
+import TimeChart from '@/components/charts/TimeChart.vue';
+import {
+  useLayerDashboard,
+  useLayerDashboardConfig,
+} from '@/composables/useLayerDashboard';
+import { useSelectedService } from '@/composables/useSelectedService';
+import { fmtMetric } from '@/utils/formatters';
+
+const route = useRoute();
+const layerKey = computed(() => String(route.params.layerKey ?? ''));
+const { selectedId } = useSelectedService();
+const { config, isLoading: configLoading } = useLayerDashboardConfig(layerKey);
+const { data, isFetching, error } = useLayerDashboard(layerKey, selectedId);
+
+const widgets = computed(() => config.value?.widgets ?? []);
+const resultsById = computed(() => {
+  const out = new Map<string, NonNullable<typeof 
data.value>['widgets'][number]>();
+  for (const r of data.value?.widgets ?? []) out.set(r.id, r);
+  return out;
+});
+const serviceText = computed(() => data.value?.service ?? selectedId.value ?? 
'(auto)');
+const reachable = computed(() => data.value?.reachable !== false);
+const errorText = computed(() => data.value?.error ?? (error.value ? 
String(error.value) : null));
+</script>
+
+<template>
+  <div class="dash-tab">
+    <header class="dash-head">
+      <div>
+        <div class="kicker">Dashboards</div>
+        <h2>{{ widgets.length }} widget{{ widgets.length === 1 ? '' : 's' 
}}</h2>
+        <p class="sub">
+          MQE scoped to <code>{{ serviceText }}</code>.
+          Refreshes every 60s. <RouterLink to="/setup">Customize columns + 
KPIs</RouterLink>.
+        </p>
+      </div>
+      <div class="state">
+        <span v-if="isFetching" class="badge fetch">refreshing</span>
+        <span v-else-if="!reachable" class="badge err">OAP unreachable</span>
+        <span v-else class="badge ok">live</span>
+      </div>
+    </header>
+
+    <div v-if="!reachable" class="banner err">
+      <strong>OAP unreachable.</strong>
+      {{ errorText ?? 'Widgets are showing nothing — check the BFF is up and 
OAP is reachable.' }}
+    </div>
+
+    <div v-if="configLoading" class="empty">Loading dashboard config…</div>
+    <div v-else-if="widgets.length === 0" class="empty">
+      No widgets defined for this layer. Phase 7 admin will let operators add 
their own.
+    </div>
+    <div v-else class="grid">
+      <div
+        v-for="w in widgets"
+        :key="w.id"
+        class="widget sw-card"
+        :style="{
+          gridColumn: `span ${w.w}`,
+          gridRow: `span ${w.h}`,
+        }"
+      >
+        <div class="w-head" :title="w.tip">
+          <h4>{{ w.title }}</h4>
+          <span v-if="w.unit" class="unit">{{ w.unit }}</span>
+        </div>
+        <div class="w-body">
+          <template v-if="resultsById.get(w.id)?.error">
+            <span class="muted">{{ resultsById.get(w.id)!.error }}</span>
+          </template>
+          <template v-else-if="w.type === 'card'">
+            <div class="card-value">
+              <span class="num" :class="{ muted: resultsById.get(w.id)?.value 
== null }">
+                {{ fmtMetric(resultsById.get(w.id)?.value ?? null) }}
+              </span>
+              <span v-if="w.unit" class="unit">{{ w.unit }}</span>
+            </div>
+          </template>
+          <template v-else-if="w.type === 'line'">
+            <TimeChart
+              v-if="resultsById.get(w.id)?.series?.length"
+              :series="resultsById.get(w.id)!.series!"
+              :unit="w.unit"
+              :height="Math.max(120, w.h * 14)"
+            />
+            <span v-else class="muted">no data</span>
+          </template>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.dash-tab {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 4px 0 0;
+}
+.dash-head {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+}
+.kicker {
+  font-size: 10px;
+  text-transform: uppercase;
+  letter-spacing: 0.1em;
+  color: var(--sw-accent);
+}
+.dash-head h2 {
+  margin: 2px 0 4px;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  letter-spacing: -0.01em;
+}
+.dash-head .sub {
+  margin: 0;
+  font-size: 11px;
+  color: var(--sw-fg-2);
+  line-height: 1.5;
+}
+.dash-head .sub code {
+  font-family: var(--sw-mono);
+  font-size: 10.5px;
+  background: var(--sw-bg-2);
+  padding: 1px 4px;
+  border-radius: 3px;
+}
+.dash-head .sub a {
+  color: var(--sw-accent-2);
+  text-decoration: none;
+}
+.state {
+  margin-left: auto;
+}
+.badge {
+  font-size: 10px;
+  padding: 3px 8px;
+  border-radius: 999px;
+  font-weight: 500;
+}
+.badge.ok {
+  color: var(--sw-ok);
+  background: rgba(34, 197, 94, 0.1);
+}
+.badge.fetch {
+  color: var(--sw-info);
+  background: rgba(96, 165, 250, 0.1);
+}
+.badge.err {
+  color: var(--sw-err);
+  background: rgba(239, 68, 68, 0.1);
+}
+.banner.err {
+  padding: 8px 12px;
+  background: var(--sw-err-soft);
+  border: 1px solid rgba(239, 68, 68, 0.3);
+  border-radius: 6px;
+  color: #f87171;
+  font-size: 11.5px;
+}
+.empty {
+  padding: 32px;
+  text-align: center;
+  color: var(--sw-fg-3);
+  font-size: 12px;
+}
+.grid {
+  display: grid;
+  grid-template-columns: repeat(24, 1fr);
+  grid-auto-rows: 14px;
+  gap: 10px;
+}
+.widget {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  overflow: hidden;
+}
+.w-head {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.w-head h4 {
+  margin: 0;
+  font-size: 11.5px;
+  font-weight: 600;
+  color: var(--sw-fg-0);
+  letter-spacing: -0.01em;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.w-head .unit {
+  font-size: 10px;
+  color: var(--sw-fg-3);
+  flex: 0 0 auto;
+}
+.w-body {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 8px 12px;
+  min-height: 0;
+}
+.card-value {
+  display: flex;
+  align-items: baseline;
+  gap: 4px;
+}
+.card-value .num {
+  font-size: 26px;
+  font-weight: 700;
+  color: var(--sw-fg-0);
+  font-variant-numeric: tabular-nums;
+  letter-spacing: -0.02em;
+}
+.card-value .num.muted {
+  color: var(--sw-fg-3);
+}
+.card-value .unit {
+  font-size: 11px;
+  color: var(--sw-fg-3);
+}
+.muted {
+  color: var(--sw-fg-3);
+  font-size: 11px;
+}
+.w-body :deep(.time-chart) {
+  width: 100%;
+}
+
+@media (max-width: 1100px) {
+  .grid {
+    grid-template-columns: repeat(12, 1fr);
+  }
+  .widget {
+    grid-column: span 12 !important;
+  }
+}
+</style>
diff --git a/packages/api-client/src/dashboard.ts 
b/packages/api-client/src/dashboard.ts
new file mode 100644
index 0000000..9f93bec
--- /dev/null
+++ b/packages/api-client/src/dashboard.ts
@@ -0,0 +1,86 @@
+/*
+ * 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.
+ */
+
+/**
+ * Per-layer dashboard wire types.
+ *
+ * The Dashboards tab on each layer page renders a grid of widgets. Each
+ * widget is one or more MQE expressions plus presentation hints (chart
+ * type, unit, position in a 24-column grid — mirrors booster-ui's
+ * grid units for visual parity).
+ *
+ * Defaults per layer are seeded from `apps/bff/src/dashboard/defaults.ts`
+ * — those defaults are lifted from the equivalent booster-ui templates
+ * so the metric coverage matches what operators expect on day one.
+ * Phase 7 admin lets operators edit + persist their own widget set.
+ */
+
+export type DashboardWidgetType = 'card' | 'line';
+
+export interface DashboardWidget {
+  /** Stable id within the layer's dashboard. */
+  id: string;
+  title: string;
+  /** Hover tip — typically the booster-ui `widget.tips`. */
+  tip?: string;
+  type: DashboardWidgetType;
+  /** One or more MQE expressions. `card` collapses to a scalar (avg);
+   *  `line` renders one labeled series per expression. */
+  expressions: string[];
+  /** Suffix unit (`%`, `ms`, `calls / min`). */
+  unit?: string;
+  /** 24-column grid coordinates — operator can re-layout later. */
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+}
+
+export interface DashboardConfig {
+  /** Layer enum (UPPER_SNAKE). */
+  layer: string;
+  /** Widget set. Order is irrelevant — grid coords drive placement. */
+  widgets: DashboardWidget[];
+}
+
+export interface DashboardSeries {
+  label: string;
+  data: Array<number | null>;
+}
+
+export interface DashboardWidgetResult {
+  id: string;
+  /** Set when every MQE expression for this widget errored. */
+  error?: string;
+  /** `card` payload — single scalar (avg across the time window). */
+  value?: number | null;
+  /** `line` payload — one entry per expression. */
+  series?: DashboardSeries[];
+}
+
+export interface DashboardResponse {
+  layer: string;
+  /** Service name the widgets were scoped to. `null` for layer-wide. */
+  service: string | null;
+  generatedAt: number;
+  step: 'MINUTE' | 'HOUR' | 'DAY';
+  durationStart: string;
+  durationEnd: string;
+  widgets: DashboardWidgetResult[];
+  reachable: boolean;
+  error?: string;
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index a75bc18..02852a9 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -27,6 +27,14 @@ export type {
   ThroughputConfig,
 } from './setup.js';
 export type { LandingAggregates, LandingResponse, LandingServiceRow } from 
'./landing.js';
+export type {
+  DashboardConfig,
+  DashboardResponse,
+  DashboardSeries,
+  DashboardWidget,
+  DashboardWidgetResult,
+  DashboardWidgetType,
+} from './dashboard.js';
 export type { OapInfo } from './oap-info.js';
 export { parseOapTimezoneMinutes } from './oap-info.js';
 export {


Reply via email to