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 {