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 890c90aed1f2cf9477420b3c99717e09a075f2fd Author: Wu Sheng <[email protected]> AuthorDate: Thu May 21 20:14:19 2026 +0800 dashboard: add `table` widget type; align K8S to upstream Labeled latest(...) metrics (pod phase per service, node condition, deployment replicas, …) can't be a scalar card or a time-series line — booster-ui renders them as a Table. Adds a `table` widget type: the BFF turns each labeled result into a name→value row (name = label values), with optional column headers + an optional value column; a TableWidget renders the scrollable key/value table. K8S service dashboard rebuilt to match the upstream k8s-cluster layout: 8 total Cards, 3 resource Area lines, and 6 Tables (node/service/pod status, deployment status/replicas) in the upstream grouping + order. Validated against demo: tables return per-label rows, cards real counts. --- apps/bff/src/bundled_templates/layers/k8s.json | 268 ++++++++++++++------- apps/bff/src/http/query/dashboard.ts | 46 +++- .../render/layer-dashboard/LayerDashboardsView.vue | 13 + apps/ui/src/render/widgets/TableWidget.vue | 99 ++++++++ packages/api-client/src/dashboard.ts | 27 ++- packages/api-client/src/index.ts | 1 + 6 files changed, 361 insertions(+), 93 deletions(-) diff --git a/apps/bff/src/bundled_templates/layers/k8s.json b/apps/bff/src/bundled_templates/layers/k8s.json index acec1df..c0d3fdd 100644 --- a/apps/bff/src/bundled_templates/layers/k8s.json +++ b/apps/bff/src/bundled_templates/layers/k8s.json @@ -93,161 +93,247 @@ "dashboards": { "service": [ { - "id": "cpu_resources", - "title": "Cluster CPU Resources", - "tip": "Allocatable / capacity / requests / limits across the cluster (millicores).", + "id": "node_total", + "title": "Node Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_node_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "namespace_total", + "title": "Namespace Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_namespace_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "deployment_total", + "title": "Deployment Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_deployment_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "statefulset_total", + "title": "StatefulSet Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_statefulset_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "daemonset_total", + "title": "DaemonSet Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_daemonset_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "service_total", + "title": "Service Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_service_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "pod_total", + "title": "Pod Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_pod_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "container_total", + "title": "Container Total", + "type": "card", + "expressions": [ + "latest(k8s_cluster_container_total)" + ], + "span": 3, + "rowSpan": 1, + "format": "int" + }, + { + "id": "cpu_res", + "title": "CPU Resources", + "tip": "Cluster CPU capacity vs requests / limits / allocatable (millicores).", "type": "line", - "unit": "m", "expressions": [ "k8s_cluster_cpu_cores", - "k8s_cluster_cpu_cores_allocatable", "k8s_cluster_cpu_cores_requests", - "k8s_cluster_cpu_cores_limits" + "k8s_cluster_cpu_cores_limits", + "k8s_cluster_cpu_cores_allocatable" ], "expressionLabels": [ - "total", - "allocatable", - "requests", - "limits" + "Capacity", + "Requests", + "Limits", + "Allocatable" ], + "unit": "m", "span": 4, "rowSpan": 2 }, { - "id": "mem_resources", - "title": "Cluster Memory Resources", + "id": "mem_res", + "title": "Memory Resources", + "tip": "Cluster memory requests / allocatable / limits / total.", "type": "line", - "unit": "Gi", "expressions": [ - "k8s_cluster_memory_total/1024/1024/1024", - "k8s_cluster_memory_allocatable/1024/1024/1024", "k8s_cluster_memory_requests/1024/1024/1024", - "k8s_cluster_memory_limits/1024/1024/1024" + "k8s_cluster_memory_allocatable/1024/1024/1024", + "k8s_cluster_memory_limits/1024/1024/1024", + "k8s_cluster_memory_total/1024/1024/1024" ], "expressionLabels": [ - "total", - "allocatable", - "requests", - "limits" + "Requests", + "Allocatable", + "Limits", + "Total" ], + "unit": "Gi", "span": 4, "rowSpan": 2 }, { - "id": "storage_resources", - "title": "Cluster Storage Resources", + "id": "storage_res", + "title": "Storage Resources", + "tip": "Cluster ephemeral-storage total vs allocatable.", "type": "line", - "unit": "Gi", "expressions": [ "k8s_cluster_storage_total/1024/1024/1024", "k8s_cluster_storage_allocatable/1024/1024/1024" ], "expressionLabels": [ - "total", - "allocatable" + "Total", + "Allocatable" ], + "unit": "Gi", "span": 4, "rowSpan": 2 }, { - "id": "pod_totals", - "title": "Pod / Container Totals", - "type": "line", + "id": "node_status", + "title": "Node Status", + "tip": "Per-node Kubernetes conditions currently true/unknown (Ready, *Pressure, \u2026).", + "type": "table", "expressions": [ - "k8s_cluster_pod_total", - "k8s_cluster_container_total" + "latest(k8s_cluster_node_status)" ], - "expressionLabels": [ - "pods", - "containers" + "tableHeaders": [ + "Node \u00b7 Condition", + "" ], - "span": 3, - "rowSpan": 2, - "format": "int" + "showTableValues": false, + "span": 4, + "rowSpan": 4 }, { - "id": "deployment_replicas", - "title": "Deployment Replicas", - "tip": "Spec vs ready/status replica counts. Divergence indicates rollout issues.", - "type": "line", + "id": "deployment_status", + "title": "Deployment Status", + "tip": "Deployments reporting the Available condition.", + "type": "table", "expressions": [ - "latest(k8s_cluster_deployment_spec_replicas)", "latest(k8s_cluster_deployment_status)" ], - "expressionLabels": [ - "spec", - "status" + "tableHeaders": [ + "Deployment \u00b7 Available", + "" ], - "span": 3, - "rowSpan": 2, - "format": "int" + "showTableValues": false, + "span": 4, + "rowSpan": 4 }, { - "id": "pod_waiting", - "title": "Pods Waiting", - "type": "card", + "id": "deployment_replicas", + "title": "Deployment Spec Replicas", + "tip": "Desired replica count per deployment.", + "type": "table", "expressions": [ - "latest(k8s_cluster_pod_status_waiting)" + "latest(k8s_cluster_deployment_spec_replicas)" ], - "span": 3, - "rowSpan": 1, - "format": "int" - }, - { - "id": "pod_not_running", - "title": "Pods Not Running", - "type": "card", - "expressions": [ - "latest(k8s_cluster_pod_status_not_running)" + "tableHeaders": [ + "Deployment", + "Replicas" ], - "span": 3, - "rowSpan": 1, - "format": "int" + "showTableValues": true, + "span": 4, + "rowSpan": 4 }, { "id": "service_pod_status", - "title": "Service Pod Status", - "tip": "Aggregate health of pods backing K8s Service objects.", - "type": "card", + "title": "Service Status", + "tip": "Pods backing each K8s Service, by phase (Running / Pending / Failed / \u2026).", + "type": "table", "expressions": [ "latest(k8s_cluster_service_pod_status)" ], + "tableHeaders": [ + "Service \u00b7 Phase", + "" + ], + "showTableValues": false, "span": 4, - "rowSpan": 1, - "format": "int" + "rowSpan": 4 }, { - "id": "node_status", - "title": "Node Status", - "tip": "Sum of node Ready states.", - "type": "card", + "id": "pod_not_running", + "title": "Pod Status Not Running", + "tip": "Pods in any non-Running phase.", + "type": "table", "expressions": [ - "latest(k8s_cluster_node_status)" + "latest(k8s_cluster_pod_status_not_running)" + ], + "tableHeaders": [ + "Pod \u00b7 Status", + "" ], + "showTableValues": false, "span": 4, - "rowSpan": 1, - "format": "int" + "rowSpan": 4 }, { - "id": "namespace_totals", - "title": "Workload Totals", - "tip": "Namespaces / deployments / statefulsets / daemonsets across the cluster.", - "type": "line", + "id": "pod_waiting", + "title": "Pod Status Waiting", + "tip": "Containers in a waiting state, by reason.", + "type": "table", "expressions": [ - "latest(k8s_cluster_namespace_total)", - "latest(k8s_cluster_deployment_total)", - "latest(k8s_cluster_statefulset_total)", - "latest(k8s_cluster_daemonset_total)" + "latest(k8s_cluster_pod_status_waiting)" ], - "expressionLabels": [ - "namespaces", - "deployments", - "statefulsets", - "daemonsets" + "tableHeaders": [ + "Container \u00b7 Pod \u00b7 Reason", + "" ], + "showTableValues": false, "span": 4, - "rowSpan": 2, - "format": "int" + "rowSpan": 4 } ], "instance": [ diff --git a/apps/bff/src/http/query/dashboard.ts b/apps/bff/src/http/query/dashboard.ts index 8bcffa8..71f13e0 100644 --- a/apps/bff/src/http/query/dashboard.ts +++ b/apps/bff/src/http/query/dashboard.ts @@ -62,7 +62,7 @@ export const widgetSchema = z.object({ id: z.string().min(1), title: z.string(), tip: z.string().optional(), - type: z.enum(['card', 'line', 'top', 'record']), + type: z.enum(['card', 'line', 'top', 'record', 'table']), // Bumped from 8 to 16: JVM Memory Detail carries 11 pool metrics // (code cache + young/old/survivor/permgen/metaspace + z-heap + // compressed class space + 3 segmented codeheaps), and a few of the @@ -73,6 +73,8 @@ export const widgetSchema = z.object({ expressionUnits: z.array(z.string()).max(16).optional(), expressionAxes: z.array(z.number().int().min(0).max(1)).max(16).optional(), unit: z.string().optional(), + tableHeaders: z.tuple([z.string(), z.string()]).optional(), + showTableValues: z.boolean().optional(), span: z.number().int().min(1).max(12).optional(), rowSpan: z.number().int().min(1).max(64).optional(), visibleWhen: z.string().optional(), @@ -386,6 +388,41 @@ export function parseTopList( }); } +/** + * Extract `table` rows from a LABELED `latest(...)` MQE response. Each + * result is one label combination (e.g. `{phase: Running, service: x}` + * or `{condition: Ready, node: y}`); the row name joins the label + * VALUES (the status/phase/condition/entity dimensions) and the value + * is the latest non-null bucket. Mirrors booster-ui's Table for + * label-dimensioned meters that a scalar card / time-series line can't + * represent. Rows are sorted by name for a stable render. + */ +export function parseTable( + r: MqeResultShape | undefined, +): Array<{ name: string; value: number | null }> | null { + if (!r || r.error) return null; + const results = r.results ?? []; + if (results.length === 0) return null; + const rows = results.map((rs) => { + const labels = rs.metric?.labels ?? []; + const name = + labels.length > 0 + ? labels.map((l) => l.value).join(' · ') + : (rs.values?.[0]?.id ?? '—'); + // `latest(...)` yields one bucket, but be defensive: take the last + // non-null value across the result's buckets. + let value: number | null = null; + for (const v of rs.values ?? []) { + if (v.value === null || v.value === undefined) continue; + const n = Number(v.value); + if (Number.isFinite(n)) value = n; + } + return { name, value }; + }); + rows.sort((a, b) => a.name.localeCompare(b.name)); + return rows; +} + export function registerDashboardQueryRoute(app: FastifyInstance, deps: DashboardRouteDeps): void { const auth = requireAuth(deps); app.post( @@ -640,6 +677,13 @@ export function registerDashboardQueryRoute(app: FastifyInstance, deps: Dashboar }; } + if (widget.type === 'table') { + // Labeled latest(...) metric → one row per label combination. + const rows = parseTable(data[`w${wIdx}_e0`]); + if (!rows) return { id: widget.id, error: 'no data' }; + return { id: widget.id, table: rows }; + } + if (widget.type === 'record') { // RECORD-typed MQE (slow SQL / slow statements) — the OAP // response is owner-keyed like topList but each entry also diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue index 9a14baf..ff37698 100644 --- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue +++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue @@ -31,6 +31,7 @@ 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 TableWidget from '@/render/widgets/TableWidget.vue'; import { colorForMetric } from '@/utils/metricColor'; import { useLayerDashboard, useLayerDashboardConfig } from '@/render/layer-dashboard/useLayerDashboard'; import { useLayerPageOrchestrator } from '@/render/layer-dashboard/useLayerPageOrchestrator'; @@ -471,6 +472,7 @@ function isVisible( topList?: Array<unknown>; topGroups?: Array<{ items: Array<unknown> }>; records?: Array<unknown>; + table?: Array<unknown>; } | undefined, ): boolean { @@ -769,6 +771,17 @@ function isVisible( /> <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) ? 'loading…' : 'no data' }}</span> </template> + <template v-else-if="w.type === 'table'"> + <TableWidget + v-if="resultsById.get(w.id)?.table?.length" + :rows="resultsById.get(w.id)!.table!" + :headers="w.tableHeaders" + :show-values="w.showTableValues !== false" + :unit="w.unit" + :format="w.format" + /> + <span v-else class="muted">{{ isFetching && !resultsById.has(w.id) ? 'loading…' : 'no data' }}</span> + </template> </div> </div> </div> diff --git a/apps/ui/src/render/widgets/TableWidget.vue b/apps/ui/src/render/widgets/TableWidget.vue new file mode 100644 index 0000000..e240002 --- /dev/null +++ b/apps/ui/src/render/widgets/TableWidget.vue @@ -0,0 +1,99 @@ +<!-- + 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. +--> +<!-- + `table` dashboard widget: a labeled latest(...) metric rendered as a + scrollable name→value table. Each row is one label combination + (status / phase / condition / entity dimension). The value column is + optional (showValues=false renders a presence list, e.g. node + conditions whose value is always 1). +--> +<script setup lang="ts"> +import { computed } from 'vue'; +import type { DashboardTableRow } from '@skywalking-horizon-ui/api-client'; +import { fmtMetricAs } from '@/utils/formatters'; + +const props = withDefaults( + defineProps<{ + rows: DashboardTableRow[]; + headers?: [string, string]; + showValues?: boolean; + unit?: string; + format?: 'int' | 'decimal' | 'compact'; + }>(), + { showValues: true }, +); + +const cols = computed(() => props.headers ?? ['Name', 'Value']); +function fmt(v: number | null): string { + if (v === null || v === undefined) return '—'; + const s = fmtMetricAs(v, props.format); + return props.unit ? `${s} ${props.unit}` : s; +} +</script> + +<template> + <div class="tw"> + <table class="tw__table"> + <thead> + <tr> + <th>{{ cols[0] }}</th> + <th v-if="showValues" class="tw__num">{{ cols[1] }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(r, i) in rows" :key="`${r.name}-${i}`"> + <td class="tw__name mono" :title="r.name">{{ r.name }}</td> + <td v-if="showValues" class="tw__num mono">{{ fmt(r.value) }}</td> + </tr> + </tbody> + </table> + </div> +</template> + +<style scoped> +.tw { height: 100%; overflow: auto; } +.tw__table { + width: 100%; + border-collapse: collapse; + font-size: 11.5px; +} +.tw__table th { + position: sticky; + top: 0; + text-align: left; + font-weight: 600; + color: var(--sw-fg-2); + background: var(--sw-bg-1); + padding: 4px 8px; + border-bottom: 1px solid var(--sw-line); + white-space: nowrap; +} +.tw__table td { + padding: 3px 8px; + border-bottom: 1px solid var(--sw-line-2, var(--sw-line)); + color: var(--sw-fg-1); +} +.tw__name { + max-width: 0; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tw__num { text-align: right; white-space: nowrap; color: var(--sw-fg-0); } +.tw__table tbody tr:hover td { background: var(--sw-bg-2); } +</style> diff --git a/packages/api-client/src/dashboard.ts b/packages/api-client/src/dashboard.ts index 214490b..99177b1 100644 --- a/packages/api-client/src/dashboard.ts +++ b/packages/api-client/src/dashboard.ts @@ -43,8 +43,14 @@ * optional refs (trace id, span id) rather than a metric * sample. The runtime is responsible for the table render; * the admin canvas previews with mock rows. + * table — key→value table for a LABELED `latest(...)` metric. Each + * label combination becomes a row (name = label values), + * with an optional value column. Mirrors booster-ui's Table + * graph for label-dimensioned meters (pod phase per service, + * node condition, deployment replicas, …) that a scalar card + * or a time-series line cannot represent. */ -export type DashboardWidgetType = 'card' | 'line' | 'top' | 'record'; +export type DashboardWidgetType = 'card' | 'line' | 'top' | 'record' | 'table'; /** * Per-entity dashboard scope. Each layer carries an independent widget @@ -108,6 +114,14 @@ export interface DashboardWidget { expressionAxes?: number[]; /** Suffix unit (`%`, `ms`, `calls / min`). */ unit?: string; + /** `table` widget: column headers `[nameColumn, valueColumn]`. The + * name column labels the label-derived row key; the value column + * labels the metric value. Defaults to `['Name', 'Value']`. */ + tableHeaders?: [string, string]; + /** `table` widget: show the value column. `false` renders a + * presence/name-only list (e.g. node conditions, where the value is + * always 1). Defaults to `true`. */ + showTableValues?: boolean; /** * Numeric formatting override. Defaults to the SPA's smart * compact-readable rule (1 decimal under 100, integer ≥ 100, SI @@ -178,6 +192,14 @@ export interface DashboardTopItem { value: number | null; } +/** One row of a `table` widget — a single labeled result of a + * `latest(...)` metric. `name` is built from the result's label + * values (the status / phase / condition / entity dimensions). */ +export interface DashboardTableRow { + name: string; + value: number | null; +} + /** * One row in a `record` widget. RECORD-typed MQE results (e.g. slow * SQL statements) carry a primary name (the statement / endpoint / @@ -230,6 +252,9 @@ export interface DashboardWidgetResult { * expression drives the list; subsequent expressions are ignored for * now (a future iteration could promote them to extra columns). */ records?: DashboardRecordItem[]; + /** `table` payload — one row per labeled result of the first + * expression (a `latest(...)` of a labeled metric). */ + table?: DashboardTableRow[]; } export interface DashboardResponse { diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 2167da4..c49305d 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -46,6 +46,7 @@ export type { DashboardScope, DashboardSeries, DashboardTopItem, + DashboardTableRow, DashboardWidget, DashboardWidgetResult, DashboardWidgetType,
