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
commit a20a7cdaf40b1775126f6127d5110dd69c69d302 Author: Wu Sheng <[email protected]> AuthorDate: Tue May 12 21:22:27 2026 +0800 service dashboard: cpm→RPM, apdex replaces p99 in summary, TopList widget Layer summary columns updated per operator preference: cpm → label 'RPM' (no unit — RPM is the unit itself) p99 → REPLACED by apdex (apdex column added; service_apdex/10000) KPI label format: 'label(unit)' when unit is set — e.g. 'SLA(%)', 'err(%)'. Value stays the bare number. Service dashboard rewritten to match the design's Service screen (no Dependencies widget per operator instruction): - Left col (span 3, rowSpan 4): Top 20 endpoints by traffic - Center+right (span 9, 2 rows of 3 line charts × span 3, rowSpan 2): Row 1: Apdex · Success Rate · Traffic Row 2: Avg Response Time · p99 · Response Time Percentile - Percentile widget uses ONE relabels() MQE returning 5 series (p50/75/90/95/99) instead of 5 separate expressions. New widget type: 'top'. - BFF parses top_n() result into [{ name, value }] entries - Owner.endpointName / serviceInstanceName / serviceName preferred over the bare id so rows are human-readable. - GraphQL fragment fetches metric.labels + value.owner so labeled line series (relabels) and top-list owner names both arrive. - New TopList.vue renders a compact rank+name+bar+value row list. Line widgets now collapse every result from every expression — one MQE returning N labeled series produces N lines, label taken from the last metric.labels entry (the relabels-derived key). --- apps/bff/src/dashboard/routes.ts | 131 +++++++++++++++--- apps/bff/src/layers/config/general.json | 173 ++++++++++++------------ apps/ui/src/components/charts/TopList.vue | 129 ++++++++++++++++++ apps/ui/src/views/layer/LayerDashboardsView.vue | 14 ++ apps/ui/src/views/layer/LayerShell.vue | 16 ++- packages/api-client/src/dashboard.ts | 15 +- packages/api-client/src/index.ts | 1 + 7 files changed, 367 insertions(+), 112 deletions(-) diff --git a/apps/bff/src/dashboard/routes.ts b/apps/bff/src/dashboard/routes.ts index 595079b..4b2649e 100644 --- a/apps/bff/src/dashboard/routes.ts +++ b/apps/bff/src/dashboard/routes.ts @@ -62,7 +62,7 @@ const widgetSchema = z.object({ id: z.string().min(1), title: z.string(), tip: z.string().optional(), - type: z.enum(['card', 'line']), + type: z.enum(['card', 'line', 'top']), expressions: z.array(z.string().min(1)).min(1).max(8), unit: z.string().optional(), span: z.number().int().min(1).max(12).optional(), @@ -81,8 +81,27 @@ const bodySchema = z.object({ scope: scopeSchema.optional(), }); +interface MqeOwner { + scope?: string | null; + serviceName?: string | null; + serviceInstanceName?: string | null; + endpointName?: string | null; +} +interface MqeValueShape { + id?: string | null; + value?: string | null; + owner?: MqeOwner | null; +} +interface MqeLabelShape { + key: string; + value: string; +} +interface MqeMetadataShape { + labels?: MqeLabelShape[] | null; +} interface MqeValuesShape { - values?: Array<{ id?: string | null; value?: string | null }>; + metric?: MqeMetadataShape | null; + values?: MqeValueShape[]; } interface MqeResultShape { type: string; @@ -124,12 +143,22 @@ function buildFragment( normal: boolean, w: Window, ): string { + // We fetch metric.labels (for multi-series Line widgets — relabels() + // returns one labeled result per percentile) and value.id / + // owner.endpointName (for TopList widgets — top_n() returns a + // sorted list of entities + values). 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 } } }` + ` ) {\n` + + ` type error\n` + + ` results {\n` + + ` metric { labels { key value } }\n` + + ` values { id value owner { scope serviceName serviceInstanceName endpointName } }\n` + + ` }\n` + + ` }` ); } @@ -150,6 +179,62 @@ function avgOf(series: Array<number | null> | null): number | null { return xs.reduce((a, b) => a + b, 0) / xs.length; } +/** + * Time-series MQE responses can carry multiple labeled results (one + * relabel() call returns 5 results, one per percentile). Convert each + * to a `DashboardSeries`. The label preference order: + * - explicit `relabels(..., key='...')` from metric.labels + * - the OAP id field (e.g., `endpoint_percentile{p='99'}`) + * - fallback to the raw expression + */ +function parseLabeledSeries( + r: MqeResultShape | undefined, + fallbackLabel: string, +): Array<{ label: string; data: Array<number | null> }> | null { + if (!r || r.error) return null; + const out: Array<{ label: string; data: Array<number | null> }> = []; + for (const rs of r.results ?? []) { + const values = rs.values ?? []; + if (values.length === 0) continue; + const data = values.map((v) => { + if (v.value === null || v.value === undefined) return null; + const n = Number(v.value); + return Number.isFinite(n) ? n : null; + }); + // Prefer the most-specific label OAP returned. relabels() adds a + // `percentile` key; raw `service_percentile{p='99'}` shows up as + // `p='99'`. Either way, take the last (most-derived) entry. + const labels = rs.metric?.labels ?? []; + const lbl = + labels.length > 0 + ? labels[labels.length - 1].value + : values[0]?.id ?? fallbackLabel; + out.push({ label: lbl, data }); + } + return out.length > 0 ? out : null; +} + +/** Extract a sorted list from a `top_n(...)` MQE response. Owner.endpointName + * / serviceInstanceName / serviceName takes priority over the bare id + * so operators see readable rows. */ +function parseTopList( + r: MqeResultShape | undefined, +): Array<{ name: string; value: 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) => { + const name = + v.owner?.endpointName ?? + v.owner?.serviceInstanceName ?? + v.owner?.serviceName ?? + v.id ?? + '—'; + const num = v.value !== null && v.value !== undefined ? Number(v.value) : null; + return { name, value: Number.isFinite(num as number) ? (num as number) : null }; + }); +} + export function registerDashboardRoute(app: FastifyInstance, deps: DashboardRouteDeps): void { const auth = requireAuth(deps); app.post( @@ -245,25 +330,37 @@ export function registerDashboardRoute(app: FastifyInstance, deps: DashboardRout } } - // Step 3 — collapse per widget. + // Step 3 — collapse per widget. Per-type handling: + // - 'card': scalar = avg of the first non-null series + // - 'line': flatten every MQE result (one per series) — handles + // both the simple case (1 expression → 1 series) and the + // relabels() case (1 expression → N labeled series) + // - 'top': extract sorted list from the first expression 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 === 'top') { + const r = data[`w${wIdx}_e0`]; + const top = parseTopList(r); + return top ? { id: widget.id, topList: top } : { 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; + const first = widget.expressions.map((_, eIdx) => + parseSeries(data[`w${wIdx}_e${eIdx}`]), + ).find((s) => s !== null); + if (!first) return { id: widget.id, error: 'no data' }; return { id: widget.id, value: avgOf(first) }; } - return { - id: widget.id, - series: series.map((s, eIdx) => ({ - label: widget.expressions[eIdx], - data: s ?? [], - })), - }; + + // 'line' — concat every result from every expression. One MQE + // can return N labeled series (relabels()), so we don't assume + // 1:1 between expressions and series. + const flat: { label: string; data: Array<number | null> }[] = []; + widget.expressions.forEach((expr, eIdx) => { + const labeled = parseLabeledSeries(data[`w${wIdx}_e${eIdx}`], expr); + if (labeled) flat.push(...labeled); + }); + if (flat.length === 0) return { id: widget.id, error: 'no data' }; + return { id: widget.id, series: flat }; }); return reply.send({ ...baseResp, widgets: results }); diff --git a/apps/bff/src/layers/config/general.json b/apps/bff/src/layers/config/general.json index 7d45f66..25e1385 100644 --- a/apps/bff/src/layers/config/general.json +++ b/apps/bff/src/layers/config/general.json @@ -24,8 +24,8 @@ "spark": "cpm", "orderBy": "cpm", "columns": [ - { "metric": "cpm", "label": "Traffic", "unit": "rpm", "mqe": "service_cpm", "aggregation": "sum" }, - { "metric": "p99", "label": "p99", "unit": "ms", "mqe": "service_percentile{p='99'}", "aggregation": "avg" }, + { "metric": "cpm", "label": "RPM", "mqe": "service_cpm", "aggregation": "sum" }, + { "metric": "apdex", "label": "Apdex", "mqe": "service_apdex/10000", "aggregation": "avg" }, { "metric": "sla", "label": "SLA", "unit": "%", "mqe": "service_sla/100", "aggregation": "avg" }, { "metric": "err", "label": "err", "unit": "%", "mqe": "100 - service_sla/100", "aggregation": "avg" } ] @@ -33,101 +33,82 @@ "dashboards": { "service": [ { - "id": "apdex", + "id": "top_endpoints", + "title": "Top 20 endpoints by traffic", + "tip": "top_n(endpoint_cpm,20,des) — click an endpoint to drill in.", + "type": "top", + "unit": "rpm", + "expressions": ["top_n(endpoint_cpm,20,des)"], + "span": 3, + "rowSpan": 4 + }, + { + "id": "apdex_line", "title": "Apdex", - "tip": "User satisfaction score on a 0–1 scale. service_apdex is integer-times-10000 server-side.", - "type": "card", - "expressions": ["avg(service_apdex)/10000"], - "span": 4, "rowSpan": 1 + "tip": "service_apdex/10000 — 0 to 1 satisfaction score.", + "type": "line", + "expressions": ["service_apdex/10000"], + "span": 3, + "rowSpan": 2 }, { - "id": "sla", + "id": "sla_line", "title": "Success Rate", - "type": "card", + "type": "line", "unit": "%", - "expressions": ["avg(service_sla)/100"], - "span": 4, "rowSpan": 1 + "expressions": ["service_sla/100"], + "span": 3, + "rowSpan": 2 }, { - "id": "traffic", + "id": "traffic_line", "title": "Traffic", - "type": "card", + "type": "line", "unit": "rpm", - "expressions": ["avg(service_cpm)"], - "span": 4, "rowSpan": 1 + "expressions": ["service_cpm"], + "span": 3, + "rowSpan": 2 }, { - "id": "resp_time", + "id": "resp_time_line", "title": "Avg Response Time", "type": "line", "unit": "ms", "expressions": ["service_resp_time"], - "span": 6, "rowSpan": 2 + "span": 3, + "rowSpan": 2 }, { - "id": "percentile", - "title": "Response Time Percentile", - "tip": "p50 / p75 / p90 / p95 / p99 — useful for tail behavior.", + "id": "p99_line", + "title": "p99", "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'}" - ], - "span": 6, "rowSpan": 2 + "expressions": ["service_percentile{p='99'}"], + "span": 3, + "rowSpan": 2 }, { - "id": "traffic_line", - "title": "Traffic", - "type": "line", - "unit": "rpm", - "expressions": ["service_cpm"], - "span": 6, "rowSpan": 2 - }, - { - "id": "sla_line", - "title": "Success Rate", + "id": "percentile_line", + "title": "Response Time Percentile", + "tip": "Single MQE: relabels(service_percentile{p='50,75,90,95,99'},...,percentile='50,...').", "type": "line", - "unit": "%", - "expressions": ["service_sla/100"], - "span": 6, "rowSpan": 2 + "unit": "ms", + "expressions": [ + "relabels(service_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='50,75,90,95,99')" + ], + "span": 3, + "rowSpan": 2 } ], "instance": [ - { - "id": "instance_cpm", - "title": "Instance Traffic", - "type": "card", - "unit": "rpm", - "expressions": ["avg(service_instance_cpm)"], - "span": 4, "rowSpan": 1 - }, - { - "id": "instance_resp", - "title": "Instance Avg Response Time", - "type": "card", - "unit": "ms", - "expressions": ["avg(service_instance_resp_time)"], - "span": 4, "rowSpan": 1 - }, - { - "id": "instance_sla", - "title": "Instance Success Rate", - "type": "card", - "unit": "%", - "expressions": ["avg(service_instance_sla)/100"], - "span": 4, "rowSpan": 1 - }, { "id": "instance_cpm_line", "title": "Traffic", "type": "line", "unit": "rpm", "expressions": ["service_instance_cpm"], - "span": 6, "rowSpan": 2 + "span": 4, + "rowSpan": 2 }, { "id": "instance_resp_line", @@ -135,7 +116,17 @@ "type": "line", "unit": "ms", "expressions": ["service_instance_resp_time"], - "span": 6, "rowSpan": 2 + "span": 4, + "rowSpan": 2 + }, + { + "id": "instance_sla_line", + "title": "Success Rate", + "type": "line", + "unit": "%", + "expressions": ["service_instance_sla/100"], + "span": 4, + "rowSpan": 2 }, { "id": "jvm_cpu", @@ -145,53 +136,57 @@ "unit": "%", "expressions": ["instance_jvm_cpu"], "visibleWhen": "instance_jvm_cpu has value", - "span": 6, "rowSpan": 2 + "span": 6, + "rowSpan": 2 }, { "id": "jvm_heap", - "title": "JVM Heap (bytes)", + "title": "JVM Heap", "type": "line", "expressions": ["instance_jvm_memory{name='heap'}"], "visibleWhen": "instance_jvm_memory has value", - "span": 6, "rowSpan": 2 + "span": 6, + "rowSpan": 2 } ], "endpoint": [ { - "id": "endpoint_cpm", - "title": "Endpoint Traffic", - "type": "card", + "id": "endpoint_cpm_line", + "title": "Traffic", + "type": "line", "unit": "rpm", - "expressions": ["avg(endpoint_cpm)"], - "span": 4, "rowSpan": 1 + "expressions": ["endpoint_cpm"], + "span": 4, + "rowSpan": 2 }, { - "id": "endpoint_resp", - "title": "Avg Response Time", - "type": "card", + "id": "endpoint_resp_line", + "title": "Response Time", + "type": "line", "unit": "ms", - "expressions": ["avg(endpoint_resp_time)"], - "span": 4, "rowSpan": 1 + "expressions": ["endpoint_resp_time"], + "span": 4, + "rowSpan": 2 }, { - "id": "endpoint_sla", + "id": "endpoint_sla_line", "title": "Success Rate", - "type": "card", + "type": "line", "unit": "%", - "expressions": ["avg(endpoint_sla)/100"], - "span": 4, "rowSpan": 1 + "expressions": ["endpoint_sla/100"], + "span": 4, + "rowSpan": 2 }, { "id": "endpoint_percentile", - "title": "Endpoint Response Time Percentile", + "title": "Response Time Percentile", "type": "line", "unit": "ms", "expressions": [ - "endpoint_percentile{p='50'}", - "endpoint_percentile{p='95'}", - "endpoint_percentile{p='99'}" + "relabels(endpoint_percentile{p='50,75,90,95,99'},p='50,75,90,95,99',percentile='50,75,90,95,99')" ], - "span": 12, "rowSpan": 2 + "span": 12, + "rowSpan": 2 } ], "trace": [], diff --git a/apps/ui/src/components/charts/TopList.vue b/apps/ui/src/components/charts/TopList.vue new file mode 100644 index 0000000..878b8f0 --- /dev/null +++ b/apps/ui/src/components/charts/TopList.vue @@ -0,0 +1,129 @@ +<!-- + 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. +--> +<!-- + Compact sorted-list renderer for `top_n(...)` MQE results. Each row + has a name + value + a horizontal bar normalized to the row's value + vs the list max. Designed for the per-layer Service dashboard's + "Top N endpoints" widget — fits a tall narrow card. +--> +<script setup lang="ts"> +import { computed } from 'vue'; +import type { DashboardTopItem } from '@skywalking-horizon-ui/api-client'; +import { fmtMetric } from '@/utils/formatters'; + +const props = withDefaults( + defineProps<{ + items: ReadonlyArray<DashboardTopItem>; + unit?: string; + /** Bar color — defaults to the accent. */ + color?: string; + }>(), + { + color: 'var(--sw-accent)', + }, +); + +const max = computed(() => { + let m = 0; + for (const it of props.items) { + const v = it.value; + if (v !== null && Number.isFinite(v) && v > m) m = v; + } + return m || 1; +}); +function pct(v: number | null): number { + if (v === null || !Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, (v / max.value) * 100)); +} +</script> + +<template> + <div class="top-list"> + <div v-for="(it, i) in items" :key="i" class="row" :title="it.name"> + <span class="rank">{{ i + 1 }}</span> + <span class="name">{{ it.name }}</span> + <div class="bar"><div class="fill" :style="{ width: `${pct(it.value)}%`, background: color }" /></div> + <span class="value"> + {{ fmtMetric(it.value) }}<span v-if="unit" class="unit">{{ unit }}</span> + </span> + </div> + <p v-if="items.length === 0" class="empty">No data</p> + </div> +</template> + +<style scoped> +.top-list { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 2px; + width: 100%; + height: 100%; + overflow-y: auto; +} +.row { + display: grid; + grid-template-columns: 18px 1fr 48px 64px; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 1px 0; +} +.rank { + font-family: var(--sw-mono); + font-size: 9.5px; + color: var(--sw-fg-3); + text-align: right; +} +.name { + font-family: var(--sw-mono); + font-size: 11px; + color: var(--sw-fg-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bar { + height: 5px; + background: var(--sw-bg-3); + border-radius: 2px; + overflow: hidden; +} +.fill { + height: 100%; + border-radius: 2px; + transition: width 0.2s ease-out; +} +.value { + font-family: var(--sw-mono); + font-size: 10.5px; + color: var(--sw-fg-1); + text-align: right; + font-variant-numeric: tabular-nums; +} +.value .unit { + margin-left: 2px; + color: var(--sw-fg-3); + font-size: 9.5px; +} +.empty { + font-size: 11px; + color: var(--sw-fg-3); + text-align: center; + margin: 12px 0; +} +</style> diff --git a/apps/ui/src/views/layer/LayerDashboardsView.vue b/apps/ui/src/views/layer/LayerDashboardsView.vue index e707286..0ec6164 100644 --- a/apps/ui/src/views/layer/LayerDashboardsView.vue +++ b/apps/ui/src/views/layer/LayerDashboardsView.vue @@ -30,6 +30,7 @@ import { computed } from 'vue'; import { useRoute } from 'vue-router'; import type { LayerDef } from '@skywalking-horizon-ui/api-client'; import TimeChart from '@/components/charts/TimeChart.vue'; +import TopList from '@/components/charts/TopList.vue'; import { useLayerDashboard, useLayerDashboardConfig } from '@/composables/useLayerDashboard'; import { useLayerLanding } from '@/composables/useLayerLanding'; import { useLayers } from '@/composables/useLayers'; @@ -175,6 +176,14 @@ function isVisible( /> <span v-else class="muted">no data</span> </template> + <template v-else-if="w.type === 'top'"> + <TopList + v-if="resultsById.get(w.id)?.topList?.length" + :items="resultsById.get(w.id)!.topList!" + :unit="w.unit" + /> + <span v-else class="muted">no data</span> + </template> </div> </div> </div> @@ -273,6 +282,11 @@ function isVisible( justify-content: center; padding: 8px 12px; min-height: 0; + overflow: hidden; +} +.w-body :deep(.top-list) { + align-self: stretch; + justify-self: stretch; } .card-value { display: flex; diff --git a/apps/ui/src/views/layer/LayerShell.vue b/apps/ui/src/views/layer/LayerShell.vue index 8ebc5fa..51a3fc2 100644 --- a/apps/ui/src/views/layer/LayerShell.vue +++ b/apps/ui/src/views/layer/LayerShell.vue @@ -205,10 +205,11 @@ const serviceKpis = computed<HeaderKpi[]>(() => { </div> <div class="kpi-strip layer-kpis"> <div v-for="(k, i) in layerKpis" :key="i" class="kpi"> - <div class="kpi-label">{{ k.label }}</div> + <div class="kpi-label"> + {{ k.label }}<span v-if="k.unit" class="unit">({{ k.unit }})</span> + </div> <div class="kpi-value" :style="{ color: k.color }"> <span :class="{ muted: k.value == null }">{{ fmtMetric(k.value) }}</span> - <span v-if="k.unit" class="kpi-unit">{{ k.unit }}</span> </div> <Sparkline v-if="k.spark && k.spark.length > 1" @@ -240,10 +241,11 @@ const serviceKpis = computed<HeaderKpi[]>(() => { </button> <div class="kpi-strip service-kpis"> <div v-for="(k, i) in serviceKpis" :key="i" class="kpi compact"> - <span class="kpi-label inline">{{ k.label }}</span> + <span class="kpi-label inline"> + {{ k.label }}<span v-if="k.unit" class="unit">({{ k.unit }})</span> + </span> <span class="kpi-value inline" :style="{ color: k.color }"> <span :class="{ muted: k.value == null }">{{ fmtMetric(k.value) }}</span> - <span v-if="k.unit" class="kpi-unit">{{ k.unit }}</span> </span> </div> </div> @@ -430,6 +432,12 @@ const serviceKpis = computed<HeaderKpi[]>(() => { color: var(--sw-fg-3); margin-bottom: 2px; } +.kpi-label .unit { + text-transform: none; + letter-spacing: 0; + margin-left: 2px; + font-size: 9.5px; +} .kpi-value { font-size: 18px; font-weight: 600; diff --git a/packages/api-client/src/dashboard.ts b/packages/api-client/src/dashboard.ts index 2b73727..10db62a 100644 --- a/packages/api-client/src/dashboard.ts +++ b/packages/api-client/src/dashboard.ts @@ -29,7 +29,7 @@ * Phase 7 admin lets operators edit + persist their own widget set. */ -export type DashboardWidgetType = 'card' | 'line'; +export type DashboardWidgetType = 'card' | 'line' | 'top'; /** * Per-entity dashboard scope. Each layer carries an independent widget @@ -95,14 +95,25 @@ export interface DashboardSeries { data: Array<number | null>; } +export interface DashboardTopItem { + /** Service / instance / endpoint name returned by OAP. */ + name: string; + value: 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. */ + /** `line` payload — one entry per expression. The line chart picks + * up its line labels from the metric.labels relabel values returned + * by OAP when present (e.g. `percentile='99'`); otherwise the raw + * expression string is used. */ series?: DashboardSeries[]; + /** `top` payload — sorted list returned by a `top_n(...)` MQE. */ + topList?: DashboardTopItem[]; } export interface DashboardResponse { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index feeda31..c10f15c 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -32,6 +32,7 @@ export type { DashboardResponse, DashboardScope, DashboardSeries, + DashboardTopItem, DashboardWidget, DashboardWidgetResult, DashboardWidgetType,
