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,

Reply via email to