This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch feat/dashboard-tab-widgets
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git

commit 7c14646b1502e71db4938f4635b5501ccd86a406
Author: Wu Sheng <[email protected]>
AuthorDate: Thu Jun 25 11:49:28 2026 +0800

    feat(layer-dashboards): tab widgets — several widgets in one slot, lazily 
loaded
    
    A layer dashboard widget can now be a `tab` container: one grid slot holding
    multiple full widgets (card / line / top / table) shown as switchable tabs. 
Only
    the active tab is queried — switching loads its child on demand and keeps 
prior
    tabs warm (vue-query cache), so an unopened tab costs nothing.
    
    - Model: `'tab'` type + `tabs?: DashboardWidget[]` on DashboardWidget; a tab
      container carries empty `expressions` and defers to its children.
    - Render: new TabWidget.vue (tab strip + the active child, rendered through 
the
      shared chart components). LayerDashboardsView flattens each tab to its 
active
      child in the metrics request, so the BFF stays tab-unaware — a tab child
      returns the same per-widget result shape as any leaf widget, no wire 
change.
    - Editor: the widget drawer gains a `tab` type + a Tabs chip strip; 
selecting a
      chip re-points the same form to edit that child (one `editingWidget`
      indirection, no form duplication); the ⚙ Container chip edits the slot. A 
tab
      can't nest a tab. The canvas previews the tab strip.
    
    Validated against the demo OAP: editor flow end-to-end (add tab, chip strip,
    edit child, no-nest, no console errors) plus a no-regression check that 
existing
    dashboards still render all widgets. type-check / build / lint / 243 unit 
tests
    green.
---
 CHANGELOG.md                                       |   1 +
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 606 ++++++++++++++-------
 .../render/layer-dashboard/LayerDashboardsView.vue |  39 +-
 apps/ui/src/render/widgets/TabWidget.vue           | 250 +++++++++
 docs/customization/layer-templates.md              |  30 +-
 packages/api-client/src/dashboard.ts               |  19 +-
 6 files changed, 744 insertions(+), 201 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d986856..63fd258 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,7 @@ The version line is shared by every package in the monorepo 
(apps + shared packa
 
 ### Layer dashboard editor
 
+- **New `tab` widget — several widgets in one grid slot, shown as switchable 
tabs.** A layer dashboard widget can now be a `tab` container that holds any 
number of full widgets (card / line / top / table) as tabs in a single slot, 
with only the active tab queried — switching a tab loads its data on demand and 
keeps it warm, so flipping back is instant. Author it in the Layer dashboards 
admin: set a widget's type to `tab`, then add / rename / reorder / delete its 
tabs from a chip strip, e [...]
 - **The widget editor pins in place beside the canvas and always opens 
complete.** On the Layer dashboards admin, clicking a widget — anywhere on the 
board, including the bottom rows — now opens the per-widget editor pinned next 
to the canvas and fully visible, without scrolling the page (a sticky panel 
used to get clipped past the bottom of a tall board, hiding the editor's top or 
its `Up` / `Down` / `Delete` row). The move / delete controls sit in a pinned 
footer, and the editor tucks  [...]
 
 ## 0.7.0
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 41d0666..7fc53fb 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -743,14 +743,22 @@ function moveWidget(i: number, dir: -1 | 1): void {
  * ------------------------------------------------------------------- */
 
 const selectedIdx = ref<number | null>(null);
+/** Which tab of a `tab`-type widget the drawer is editing. `null` = the
+ *  tab container itself (its id / title / layout); a number = that child
+ *  widget. Reset whenever the selected widget / scope changes. */
+const activeTabIdx = ref<number | null>(null);
 /* Re-fit the drawer to the viewport when it opens / the target widget changes
  * (content height differs); the scroll + resize listeners keep it fitted 
after. */
-watch(selectedIdx, () => void nextTick(positionDrawer));
+watch(selectedIdx, () => {
+  activeTabIdx.value = null;
+  void nextTick(positionDrawer);
+});
 
 /** When the user switches scope or layer we drop the selection so the
  *  drawer doesn't refer to a widget that no longer exists. */
 watch([activeScope, selectedKey], () => {
   selectedIdx.value = null;
+  activeTabIdx.value = null;
 });
 
 const canvasEl = ref<HTMLDivElement | null>(null);
@@ -906,8 +914,72 @@ function selectWidget(i: number): void {
   selectedIdx.value = i;
 }
 
-function setWidgetFormat(v: string): void {
+/** The widget the content form actually edits: the selected widget, OR —
+ *  when it's a `tab` container with a child tab picked — that child. Every
+ *  per-widget content control (id / title / type / expressions / unit /
+ *  format / visibleWhen) binds here, so the SAME form edits a child with
+ *  no duplication. Parent-only controls (span / rowSpan, move / delete)
+ *  stay on `selectedWidget`. Falls back to the container if the child
+ *  index is stale so the form target is never null while a widget is open. */
+const editingWidget = computed<DashboardWidget | null>(() => {
   const w = selectedWidget.value;
+  if (!w) return null;
+  if (w.type === 'tab' && activeTabIdx.value !== null) return 
w.tabs?.[activeTabIdx.value] ?? w;
+  return w;
+});
+
+/** Type changes run through here so switching a widget TO `tab` seeds its
+ *  first child + clears its own (now meaningless) expressions, and leaving
+ *  `tab` restores an expression slot. A child select never offers `tab`. */
+function onWidgetTypeChange(value: string): void {
+  const w = editingWidget.value;
+  if (!w) return;
+  w.type = value as DashboardWidget['type'];
+  if (w.type === 'tab') {
+    if (!w.tabs || w.tabs.length === 0) {
+      w.tabs = [{ id: `${w.id}_tab_1`, title: 'Tab 1', type: 'card', 
expressions: [''] }];
+    }
+    w.expressions = [];
+    activeTabIdx.value = null;
+  } else if (!w.expressions || w.expressions.length === 0) {
+    w.expressions = [''];
+  }
+}
+
+/** Nested tab list ops — mirror addWidget / moveWidget / deleteWidget but
+ *  operate on the selected tab container's `tabs` array. */
+function addTab(): void {
+  const w = editingWidget.value;
+  if (!w || w.type !== 'tab') return;
+  const tabs = [...(w.tabs ?? [])];
+  const n = tabs.length + 1;
+  tabs.push({ id: `${w.id}_tab_${n}`, title: `Tab ${n}`, type: 'card', 
expressions: [''] });
+  w.tabs = tabs;
+  activeTabIdx.value = tabs.length - 1;
+}
+function deleteTab(i: number): void {
+  const w = editingWidget.value;
+  if (!w || !w.tabs) return;
+  const tabs = [...w.tabs];
+  tabs.splice(i, 1);
+  w.tabs = tabs;
+  if (activeTabIdx.value === i) activeTabIdx.value = null;
+  else if (activeTabIdx.value !== null && activeTabIdx.value > i) 
activeTabIdx.value -= 1;
+}
+function moveTab(i: number, dir: -1 | 1): void {
+  const w = editingWidget.value;
+  if (!w || !w.tabs) return;
+  const tabs = [...w.tabs];
+  const j = i + dir;
+  if (j < 0 || j >= tabs.length) return;
+  [tabs[i], tabs[j]] = [tabs[j], tabs[i]];
+  w.tabs = tabs;
+  if (activeTabIdx.value === i) activeTabIdx.value = j;
+  else if (activeTabIdx.value === j) activeTabIdx.value = i;
+}
+
+function setWidgetFormat(v: string): void {
+  const w = editingWidget.value;
   if (!w) return;
   if (v === 'int' || v === 'decimal' || v === 'compact' || v === 'duration' || 
v === 'enum') w.format = v;
   else delete w.format;
@@ -916,24 +988,24 @@ function setWidgetFormat(v: string): void {
 // `format: 'enum'` value→label editor — the valueMap is a coded-value → label
 // table (e.g. 1 → OK). Keys are renamed on blur to avoid focus loss mid-edit.
 const valueMapEntries = computed<Array<[string, string]>>(() => {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   return w?.valueMap ? Object.entries(w.valueMap) : [];
 });
 function setValueMapLabel(key: string, label: string): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   if (!w.valueMap) w.valueMap = {};
   w.valueMap[key] = label;
 }
 function setValueMapKey(oldKey: string, newKey: string): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w || !w.valueMap || newKey === oldKey || newKey in w.valueMap) return;
   const label = w.valueMap[oldKey];
   delete w.valueMap[oldKey];
   w.valueMap[newKey] = label;
 }
 function addValueMapRow(): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   if (!w.valueMap) w.valueMap = {};
   let k = 0;
@@ -941,7 +1013,7 @@ function addValueMapRow(): void {
   w.valueMap[String(k)] = '';
 }
 function removeValueMapRow(key: string): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w || !w.valueMap) return;
   delete w.valueMap[key];
   if (Object.keys(w.valueMap).length === 0) delete w.valueMap;
@@ -989,7 +1061,7 @@ async function addAndSelectWidget(): Promise<void> {
  *  multi-series `line` widgets; a single-expression card/line hides
  *  them to keep the row compact. */
 const showExprMeta = computed(() => {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   return !!w && (w.type === 'top' || (w.expressions?.length ?? 0) > 1);
 });
 function padTo<T>(arr: T[] | undefined, len: number, fill: T): T[] {
@@ -998,40 +1070,40 @@ function padTo<T>(arr: T[] | undefined, len: number, 
fill: T): T[] {
   return a;
 }
 function updateExpr(i: number, v: string): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   const a = [...w.expressions];
   a[i] = v;
   w.expressions = a;
 }
 function updateExprLabel(i: number, v: string): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   const a = padTo(w.expressionLabels, w.expressions.length, '');
   a[i] = v;
   w.expressionLabels = a;
 }
 function updateExprUnit(i: number, v: string): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   const a = padTo(w.expressionUnits, w.expressions.length, '');
   a[i] = v;
   w.expressionUnits = a;
 }
 function updateExprAxis(i: number, v: number): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   const a = padTo(w.expressionAxes, w.expressions.length, 0);
   a[i] = v === 1 ? 1 : 0;
   w.expressionAxes = a;
 }
 function addExpr(): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w) return;
   w.expressions = [...w.expressions, ''];
 }
 function removeExpr(i: number): void {
-  const w = selectedWidget.value;
+  const w = editingWidget.value;
   if (!w || w.expressions.length <= 1) return;
   const drop = <T>(arr: T[] | undefined): T[] | undefined =>
     arr ? arr.filter((_, j) => j !== i) : arr;
@@ -1828,12 +1900,12 @@ function visibleWhenHint(scope: DashboardScope): string 
{
 
 const vwKindModel = computed<VwKind>({
   get() {
-    const vw = selectedWidget.value?.visibleWhen;
+    const vw = editingWidget.value?.visibleWhen;
     if (!vw) return 'none';
     return vw.kind === 'entity' ? 'entity' : 'mqe';
   },
   set(k) {
-    const w = selectedWidget.value;
+    const w = editingWidget.value;
     if (!w) return;
     if (k === 'none') w.visibleWhen = undefined;
     else if (k === 'mqe') w.visibleWhen = { kind: 'mqe', expression: 
w.expressions?.[0] ?? '', op: 'exists' };
@@ -1842,12 +1914,12 @@ const vwKindModel = computed<VwKind>({
 });
 const vwTarget = computed<string>({
   get() {
-    const vw = selectedWidget.value?.visibleWhen;
+    const vw = editingWidget.value?.visibleWhen;
     if (!vw) return '';
     return vw.kind === 'mqe' ? vw.expression : vw.attribute;
   },
   set(v) {
-    const vw = selectedWidget.value?.visibleWhen;
+    const vw = editingWidget.value?.visibleWhen;
     if (!vw) return;
     if (vw.kind === 'mqe') vw.expression = v;
     else vw.attribute = v;
@@ -1855,10 +1927,10 @@ const vwTarget = computed<string>({
 });
 const vwOp = computed<string>({
   get() {
-    return selectedWidget.value?.visibleWhen?.op ?? 'exists';
+    return editingWidget.value?.visibleWhen?.op ?? 'exists';
   },
   set(op) {
-    const w = selectedWidget.value;
+    const w = editingWidget.value;
     const vw = w?.visibleWhen;
     if (!w || !vw) return;
     if (vw.kind === 'mqe') {
@@ -1875,16 +1947,16 @@ const vwOp = computed<string>({
   },
 });
 const vwNeedsValue = computed(() => {
-  const op = selectedWidget.value?.visibleWhen?.op;
+  const op = editingWidget.value?.visibleWhen?.op;
   return op === 'gt' || op === 'lt' || op === 'eq';
 });
 const vwValue = computed<string>({
   get() {
-    const vw = selectedWidget.value?.visibleWhen;
+    const vw = editingWidget.value?.visibleWhen;
     return vw && 'value' in vw ? String(vw.value) : '';
   },
   set(v) {
-    const vw = selectedWidget.value?.visibleWhen;
+    const vw = editingWidget.value?.visibleWhen;
     if (!vw || !('value' in vw)) return;
     if (vw.kind === 'mqe') vw.value = Number(v) || 0;
     else vw.value = v;
@@ -3800,6 +3872,21 @@ const namingTest = computed<NamingTestResult>(() => {
                       </li>
                     </ul>
                   </template>
+                  <template v-else-if="w.type === 'tab'">
+                    <!-- Container preview: the tab strip. Children render live
+                         on the dashboard; the canvas only shows the 
structure. -->
+                    <div class="cw-tabs">
+                      <span
+                        v-for="(tab, ti) in w.tabs ?? []"
+                        :key="tab.id"
+                        class="cw-tab"
+                        :class="{ on: ti === 0 }"
+                      >{{ tab.title || tab.id }}</span>
+                    </div>
+                    <p class="cw-tab-hint">
+                      {{ (w.tabs ?? []).length }} tab{{ (w.tabs ?? []).length 
=== 1 ? '' : 's' }} — only the active one is queried
+                    </p>
+                  </template>
                   <p v-else class="cw-empty">
                     Add an MQE expression in the drawer to preview.
                   </p>
@@ -3830,189 +3917,237 @@ const namingTest = computed<NamingTestResult>(() => {
             <aside v-if="selectedWidget" ref="drawerEl" class="drawer">
               <div class="drawer-head">
                 <h4>Edit widget</h4>
-                <span class="sub">{{ scopeLabel(activeScope) }} · #{{ 
(selectedIdx ?? 0) + 1 }}</span>
+                <span class="sub">{{ scopeLabel(activeScope) }} · #{{ 
(selectedIdx ?? 0) + 1 }}<template v-if="selectedWidget.type === 'tab'"> · {{ 
activeTabIdx === null ? 'container' : (editingWidget?.title || `tab 
${activeTabIdx + 1}`) }}</template></span>
                 <button class="sw-btn ghost close" type="button" title="Close" 
@click="selectedIdx = null">✕</button>
               </div>
               <div class="drawer-body">
-                <div class="d-row">
-                  <label>
-                    <span>id</span>
-                    <input class="mono" v-model="selectedWidget.id" />
-                  </label>
-                  <label class="grow">
-                    <span>Title</span>
-                    <input v-model="selectedWidget.title" />
-                  </label>
-                </div>
-                <div class="d-row">
-                  <label class="grow">
-                    <span>Tip (hover hint)</span>
-                    <input v-model="selectedWidget.tip" placeholder="—" />
-                  </label>
-                </div>
-                <div class="d-row">
-                  <label>
-                    <span>Type</span>
-                    <select v-model="selectedWidget.type">
-                      <option value="card">card</option>
-                      <option value="line">line</option>
-                      <option value="top">top</option>
-                      <option value="record">record</option>
-                    </select>
-                  </label>
-                  <label>
-                    <span>Unit</span>
-                    <input v-model="selectedWidget.unit" placeholder="—" />
-                  </label>
-                  <label>
-                    <span>Format</span>
-                    <select :value="selectedWidget.format ?? ''" 
@change="setWidgetFormat(($event.target as HTMLSelectElement).value)">
-                      <option value="">auto</option>
-                      <option value="int">int</option>
-                      <option value="decimal">decimal</option>
-                      <option value="compact">compact</option>
-                      <option value="duration">duration</option>
-                      <option v-if="selectedWidget.type === 'card'" 
value="enum">enum</option>
-                    </select>
-                  </label>
-                  <label>
-                    <span>Span</span>
-                    <input type="number" min="1" max="12" 
v-model.number="selectedWidget.span" />
-                  </label>
-                  <label>
-                    <span>Row span</span>
-                    <input type="number" min="1" max="8" 
v-model.number="selectedWidget.rowSpan" />
-                  </label>
-                </div>
-                <div v-if="selectedWidget.format === 'enum'" class="d-section">
-                  <span class="d-label">Value map (enum → label)</span>
-                  <div class="vm-rows">
-                    <div v-for="(row, i) in valueMapEntries" :key="i" 
class="vm-row">
-                      <input
-                        class="mono vm-key"
-                        :value="row[0]"
-                        @change="setValueMapKey(row[0], ($event.target as 
HTMLInputElement).value)"
-                        placeholder="0"
-                      />
-                      <span class="vm-arrow">→</span>
-                      <input
-                        class="vm-label"
-                        :value="row[1]"
-                        @input="setValueMapLabel(row[0], ($event.target as 
HTMLInputElement).value)"
-                        placeholder="Failed"
-                      />
-                      <button type="button" class="expr-del" title="Remove" 
@click="removeValueMapRow(row[0])">×</button>
-                    </div>
+                <!-- Tab container: a chip strip switches the form below 
between
+                     the container itself (⚙) and each child tab. Editing a 
chip
+                     re-points `editingWidget`, so the same form edits the 
child. -->
+                <div v-if="selectedWidget.type === 'tab'" class="d-section 
tab-editor">
+                  <span class="d-label">Tabs</span>
+                  <div class="tab-chips">
+                    <button
+                      type="button"
+                      class="tab-chip cfg"
+                      :class="{ on: activeTabIdx === null }"
+                      title="Edit the container (title, layout)"
+                      @click="activeTabIdx = null"
+                    >⚙ Container</button>
+                    <button
+                      v-for="(tab, i) in selectedWidget.tabs ?? []"
+                      :key="tab.id"
+                      type="button"
+                      class="tab-chip"
+                      :class="{ on: activeTabIdx === i }"
+                      @click="activeTabIdx = i"
+                    >
+                      <span class="tc-name">{{ tab.title || tab.id }}</span>
+                      <span class="tc-acts">
+                        <span class="tc-mv" title="Move left" 
@click.stop="moveTab(i, -1)">‹</span>
+                        <span class="tc-mv" title="Move right" 
@click.stop="moveTab(i, 1)">›</span>
+                        <span class="tc-del" title="Delete tab" 
@click.stop="deleteTab(i)">×</span>
+                      </span>
+                    </button>
+                    <button type="button" class="sw-btn ghost small" 
@click="addTab">+ tab</button>
                   </div>
-                  <button type="button" class="sw-btn ghost small" 
@click="addValueMapRow">+ value</button>
-                  <p class="d-hint">Map a coded value to a label (e.g. 1 → 
OK). Card widgets only; labels are translatable per locale.</p>
+                  <p class="d-hint">Each tab is a full widget. Only the active 
tab is queried — switching loads it on demand.</p>
                 </div>
-                <div class="d-section">
-                  <span class="d-label">MQE expressions</span>
-                  <div v-if="showExprMeta" class="expr-cols">
-                    <span class="expr-col-mqe">expression</span>
-                    <span class="expr-col-label">{{ selectedWidget.type === 
'top' ? 'tab label' : 'series label' }}</span>
-                    <span class="expr-col-unit">unit</span>
-                    <span v-if="selectedWidget.type === 'line'" 
class="expr-col-axis">axis</span>
-                    <span class="expr-col-del"></span>
+
+                <template v-if="editingWidget">
+                  <div class="d-row">
+                    <label>
+                      <span>id</span>
+                      <input class="mono" v-model="editingWidget.id" />
+                    </label>
+                    <label class="grow">
+                      <span>Title</span>
+                      <input v-model="editingWidget.title" />
+                    </label>
                   </div>
-                  <div class="expr-rows">
-                    <div v-for="(expr, i) in selectedWidget.expressions" 
:key="i" class="expr-row">
-                      <MqeExpressionInput
-                        class="expr-mqe"
-                        :model-value="expr"
-                        placeholder="instance_jvm_cpu"
-                        :title="`Expression ${i + 1}`"
-                        @update:model-value="updateExpr(i, $event)"
-                      />
-                      <input
-                        v-if="showExprMeta"
-                        class="expr-label"
-                        :value="selectedWidget.expressionLabels?.[i] ?? ''"
-                        @input="updateExprLabel(i, ($event.target as 
HTMLInputElement).value)"
-                        :placeholder="selectedWidget.type === 'top' ? 
'Traffic' : 'p99'"
-                      />
-                      <input
-                        v-if="showExprMeta"
-                        class="mono expr-unit"
-                        :value="selectedWidget.expressionUnits?.[i] ?? ''"
-                        @input="updateExprUnit(i, ($event.target as 
HTMLInputElement).value)"
-                        :placeholder="selectedWidget.unit || 'ms'"
-                      />
-                      <select
-                        v-if="showExprMeta && selectedWidget.type === 'line'"
-                        class="mono expr-axis"
-                        :value="String(selectedWidget.expressionAxes?.[i] ?? 
0)"
-                        @change="updateExprAxis(i, Number(($event.target as 
HTMLSelectElement).value))"
-                        title="Y-axis (Left / Right) — for dual-axis line 
widgets"
-                      >
-                        <option value="0">L</option>
-                        <option value="1">R</option>
-                      </select>
-                      <button
-                        type="button"
-                        class="expr-del"
-                        title="Remove expression"
-                        :disabled="selectedWidget.expressions.length <= 1"
-                        @click="removeExpr(i)"
-                      >×</button>
-                    </div>
+                  <div class="d-row">
+                    <label class="grow">
+                      <span>Tip (hover hint)</span>
+                      <input v-model="editingWidget.tip" placeholder="—" />
+                    </label>
                   </div>
-                  <button type="button" class="sw-btn ghost small expr-add" 
@click="addExpr">+ expression</button>
-                  <p class="d-hint">
-                    For <code>top</code> widgets each expression is a 
switchable tab; for
-                    <code>line</code> each is a series. Label / unit / axis 
apply per expression.
-                  </p>
-                </div>
-                <div class="d-section">
-                  <span class="d-label" :title="visibleWhenHint(activeScope)">
-                    Visible when (optional)
-                  </span>
-                  <div class="vw-row">
-                    <select class="mono" v-model="vwKindModel">
-                      <option value="none">Always visible</option>
-                      <option value="mqe">MQE metric…</option>
-                      <option value="entity">Entity attribute…</option>
-                    </select>
-                    <template v-if="vwKindModel === 'mqe'">
-                      <MqeExpressionInput
-                        class="vw-target"
-                        v-model="vwTarget"
-                        placeholder="instance_jvm_cpu"
-                        title="Gate expression"
-                      />
-                      <select class="mono" v-model="vwOp">
-                        <option value="exists">has value</option>
-                        <option value="gt">&gt;</option>
-                        <option value="lt">&lt;</option>
+                  <div class="d-row">
+                    <label>
+                      <span>Type</span>
+                      <select :value="editingWidget.type" 
@change="onWidgetTypeChange(($event.target as HTMLSelectElement).value)">
+                        <option value="card">card</option>
+                        <option value="line">line</option>
+                        <option value="top">top</option>
+                        <option value="record">record</option>
+                        <!-- A tab can't nest a tab — offer the container type 
at top level only. -->
+                        <option v-if="activeTabIdx === null" 
value="tab">tab</option>
                       </select>
+                    </label>
+                    <template v-if="editingWidget.type !== 'tab'">
+                      <label>
+                        <span>Unit</span>
+                        <input v-model="editingWidget.unit" placeholder="—" />
+                      </label>
+                      <label>
+                        <span>Format</span>
+                        <select :value="editingWidget.format ?? ''" 
@change="setWidgetFormat(($event.target as HTMLSelectElement).value)">
+                          <option value="">auto</option>
+                          <option value="int">int</option>
+                          <option value="decimal">decimal</option>
+                          <option value="compact">compact</option>
+                          <option value="duration">duration</option>
+                          <option v-if="editingWidget.type === 'card'" 
value="enum">enum</option>
+                        </select>
+                      </label>
                     </template>
-                    <template v-else-if="vwKindModel === 'entity'">
-                      <input class="mono vw-target" v-model="vwTarget" 
placeholder="language" />
-                      <select class="mono" v-model="vwOp">
-                        <option value="exists">exists</option>
-                        <option value="eq">equals</option>
-                      </select>
+                    <!-- Span / row span size the grid SLOT — owned by the 
container,
+                         never a child tab; only shown when editing the top 
widget. -->
+                    <template v-if="activeTabIdx === null">
+                      <label>
+                        <span>Span</span>
+                        <input type="number" min="1" max="12" 
v-model.number="selectedWidget.span" />
+                      </label>
+                      <label>
+                        <span>Row span</span>
+                        <input type="number" min="1" max="8" 
v-model.number="selectedWidget.rowSpan" />
+                      </label>
                     </template>
-                    <input
-                      v-if="vwNeedsValue"
-                      class="mono vw-val"
-                      v-model="vwValue"
-                      :type="vwKindModel === 'mqe' ? 'number' : 'text'"
-                      :placeholder="vwKindModel === 'entity' ? 'JAVA' : '0'"
-                    />
                   </div>
-                  <p v-if="vwKindModel === 'mqe' && !vwTarget.trim()" 
class="d-hint" style="color: var(--sw-warn)">
-                    Set a metric expression — an empty gate is ignored and the 
widget always shows.
-                  </p>
-                  <p class="d-hint" style="white-space: pre-line">{{ 
visibleWhenHint(activeScope) }}</p>
-                </div>
-                <div class="d-section">
-                  <label class="d-check">
-                    <input type="checkbox" v-model="selectedWidget.layerScope" 
/>
-                    <span>Layer-scoped (run MQE across the whole layer, ignore 
selected service)</span>
-                  </label>
-                </div>
+
+                  <!-- A tab CONTAINER carries no MQE / format / gate of its 
own —
+                       those belong to its children. Everything below is 
per-leaf. -->
+                  <template v-if="editingWidget.type !== 'tab'">
+                    <div v-if="editingWidget.format === 'enum'" 
class="d-section">
+                      <span class="d-label">Value map (enum → label)</span>
+                      <div class="vm-rows">
+                        <div v-for="(row, i) in valueMapEntries" :key="i" 
class="vm-row">
+                          <input
+                            class="mono vm-key"
+                            :value="row[0]"
+                            @change="setValueMapKey(row[0], ($event.target as 
HTMLInputElement).value)"
+                            placeholder="0"
+                          />
+                          <span class="vm-arrow">→</span>
+                          <input
+                            class="vm-label"
+                            :value="row[1]"
+                            @input="setValueMapLabel(row[0], ($event.target as 
HTMLInputElement).value)"
+                            placeholder="Failed"
+                          />
+                          <button type="button" class="expr-del" 
title="Remove" @click="removeValueMapRow(row[0])">×</button>
+                        </div>
+                      </div>
+                      <button type="button" class="sw-btn ghost small" 
@click="addValueMapRow">+ value</button>
+                      <p class="d-hint">Map a coded value to a label (e.g. 1 → 
OK). Card widgets only; labels are translatable per locale.</p>
+                    </div>
+                    <div class="d-section">
+                      <span class="d-label">MQE expressions</span>
+                      <div v-if="showExprMeta" class="expr-cols">
+                        <span class="expr-col-mqe">expression</span>
+                        <span class="expr-col-label">{{ editingWidget.type === 
'top' ? 'tab label' : 'series label' }}</span>
+                        <span class="expr-col-unit">unit</span>
+                        <span v-if="editingWidget.type === 'line'" 
class="expr-col-axis">axis</span>
+                        <span class="expr-col-del"></span>
+                      </div>
+                      <div class="expr-rows">
+                        <div v-for="(expr, i) in editingWidget.expressions" 
:key="i" class="expr-row">
+                          <MqeExpressionInput
+                            class="expr-mqe"
+                            :model-value="expr"
+                            placeholder="instance_jvm_cpu"
+                            :title="`Expression ${i + 1}`"
+                            @update:model-value="updateExpr(i, $event)"
+                          />
+                          <input
+                            v-if="showExprMeta"
+                            class="expr-label"
+                            :value="editingWidget.expressionLabels?.[i] ?? ''"
+                            @input="updateExprLabel(i, ($event.target as 
HTMLInputElement).value)"
+                            :placeholder="editingWidget.type === 'top' ? 
'Traffic' : 'p99'"
+                          />
+                          <input
+                            v-if="showExprMeta"
+                            class="mono expr-unit"
+                            :value="editingWidget.expressionUnits?.[i] ?? ''"
+                            @input="updateExprUnit(i, ($event.target as 
HTMLInputElement).value)"
+                            :placeholder="editingWidget.unit || 'ms'"
+                          />
+                          <select
+                            v-if="showExprMeta && editingWidget.type === 
'line'"
+                            class="mono expr-axis"
+                            :value="String(editingWidget.expressionAxes?.[i] 
?? 0)"
+                            @change="updateExprAxis(i, Number(($event.target 
as HTMLSelectElement).value))"
+                            title="Y-axis (Left / Right) — for dual-axis line 
widgets"
+                          >
+                            <option value="0">L</option>
+                            <option value="1">R</option>
+                          </select>
+                          <button
+                            type="button"
+                            class="expr-del"
+                            title="Remove expression"
+                            :disabled="editingWidget.expressions.length <= 1"
+                            @click="removeExpr(i)"
+                          >×</button>
+                        </div>
+                      </div>
+                      <button type="button" class="sw-btn ghost small 
expr-add" @click="addExpr">+ expression</button>
+                      <p class="d-hint">
+                        For <code>top</code> widgets each expression is a 
switchable tab; for
+                        <code>line</code> each is a series. Label / unit / 
axis apply per expression.
+                      </p>
+                    </div>
+                    <div class="d-section">
+                      <span class="d-label" 
:title="visibleWhenHint(activeScope)">
+                        Visible when (optional)
+                      </span>
+                      <div class="vw-row">
+                        <select class="mono" v-model="vwKindModel">
+                          <option value="none">Always visible</option>
+                          <option value="mqe">MQE metric…</option>
+                          <option value="entity">Entity attribute…</option>
+                        </select>
+                        <template v-if="vwKindModel === 'mqe'">
+                          <MqeExpressionInput
+                            class="vw-target"
+                            v-model="vwTarget"
+                            placeholder="instance_jvm_cpu"
+                            title="Gate expression"
+                          />
+                          <select class="mono" v-model="vwOp">
+                            <option value="exists">has value</option>
+                            <option value="gt">&gt;</option>
+                            <option value="lt">&lt;</option>
+                          </select>
+                        </template>
+                        <template v-else-if="vwKindModel === 'entity'">
+                          <input class="mono vw-target" v-model="vwTarget" 
placeholder="language" />
+                          <select class="mono" v-model="vwOp">
+                            <option value="exists">exists</option>
+                            <option value="eq">equals</option>
+                          </select>
+                        </template>
+                        <input
+                          v-if="vwNeedsValue"
+                          class="mono vw-val"
+                          v-model="vwValue"
+                          :type="vwKindModel === 'mqe' ? 'number' : 'text'"
+                          :placeholder="vwKindModel === 'entity' ? 'JAVA' : 
'0'"
+                        />
+                      </div>
+                      <p v-if="vwKindModel === 'mqe' && !vwTarget.trim()" 
class="d-hint" style="color: var(--sw-warn)">
+                        Set a metric expression — an empty gate is ignored and 
the widget always shows.
+                      </p>
+                      <p class="d-hint" style="white-space: pre-line">{{ 
visibleWhenHint(activeScope) }}</p>
+                    </div>
+                    <div class="d-section">
+                      <label class="d-check">
+                        <input type="checkbox" 
v-model="editingWidget.layerScope" />
+                        <span>Layer-scoped (run MQE across the whole layer, 
ignore selected service)</span>
+                      </label>
+                    </div>
+                  </template>
+                </template>
               </div>
               <!-- Pinned footer: move/delete stay visible no matter how long
                    the form scrolls (the body above owns the overflow). -->
@@ -5458,6 +5593,32 @@ const namingTest = computed<NamingTestResult>(() => {
 .cw-type.t-top    { color: #a78bfa; background: rgba(167, 139, 250, 0.12); }
 .cw-type.t-card   { color: var(--sw-accent-2); background: 
var(--sw-accent-soft); }
 .cw-type.t-record { color: #22d3ee; background: rgba(34, 211, 238, 0.12); }
+.cw-type.t-tab    { color: #34d399; background: rgba(52, 211, 153, 0.12); }
+.cw-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 3px;
+  padding: 2px 0 6px;
+  border-bottom: 1px solid var(--sw-line);
+}
+.cw-tab {
+  font-size: 10px;
+  padding: 2px 7px;
+  border-radius: 3px;
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-2);
+  white-space: nowrap;
+}
+.cw-tab.on {
+  background: var(--sw-bg-3);
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.cw-tab-hint {
+  margin: 6px 0 0;
+  font-size: 10px;
+  color: var(--sw-fg-3);
+}
 .cw-body {
   flex: 1;
   min-height: 0;
@@ -5747,6 +5908,61 @@ const namingTest = computed<NamingTestResult>(() => {
   margin: 2px 0 0;
   line-height: 1.4;
 }
+/* Tab-container editor: chip strip selecting the container or a child tab. */
+.tab-editor .tab-chips {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+  align-items: center;
+}
+.tab-chip {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 3px 8px;
+  font-size: 11px;
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  background: var(--sw-bg-1);
+  color: var(--sw-fg-2);
+  cursor: pointer;
+}
+.tab-chip:hover {
+  border-color: var(--sw-line-2);
+  color: var(--sw-fg-1);
+}
+.tab-chip.on {
+  background: var(--sw-bg-3);
+  border-color: var(--sw-accent);
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.tab-chip.cfg {
+  color: var(--sw-fg-3);
+}
+.tab-chip .tc-acts {
+  display: inline-flex;
+  gap: 3px;
+  opacity: 0.6;
+}
+.tab-chip:hover .tc-acts {
+  opacity: 1;
+}
+.tab-chip .tc-mv,
+.tab-chip .tc-del {
+  font-size: 12px;
+  line-height: 1;
+  padding: 0 1px;
+  border-radius: 2px;
+}
+.tab-chip .tc-mv:hover {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-0);
+}
+.tab-chip .tc-del:hover {
+  background: var(--sw-err-soft, rgba(239, 68, 68, 0.15));
+  color: var(--sw-err);
+}
 .d-hint code {
   font-family: var(--sw-mono);
   padding: 0 3px;
diff --git a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue 
b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
index adb83be..80b1e18 100644
--- a/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
+++ b/apps/ui/src/render/layer-dashboard/LayerDashboardsView.vue
@@ -28,12 +28,13 @@
 <script setup lang="ts">
 import { computed } from 'vue';
 import { useRoute } from 'vue-router';
-import type { LayerDef } from '@skywalking-horizon-ui/api-client';
+import type { LayerDef, DashboardWidget } from 
'@skywalking-horizon-ui/api-client';
 import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
 import RecordList from '@/render/widgets/RecordList.vue';
 import WidgetTip from '@/components/primitives/WidgetTip.vue';
 import TableWidget from '@/render/widgets/TableWidget.vue';
+import TabWidget from '@/render/widgets/TabWidget.vue';
 import { colorForMetric } from '@/utils/metricColor';
 import { useLayerDashboard, useLayerDashboardConfig } from 
'@/render/layer-dashboard/useLayerDashboard';
 import { useLayerPageOrchestrator } from 
'@/render/layer-dashboard/useLayerPageOrchestrator';
@@ -404,7 +405,32 @@ const noEntityToChart = computed<boolean>(() => {
   if (scope.value === 'endpoint') return !endpointResolvable.value;
   return false;
 });
-const widgetsForQuery = computed(() => config.value?.widgets ?? []);
+// Active tab per `tab`-type widget (by widget id; default first tab). Owned
+// here because it drives the lazy flatten below — only the active child is
+// queried. Declared above its consumer (widgetsForQuery) per the TDZ rule.
+const activeTabByWidget = ref<Record<string, number>>({});
+function activeTabIndex(widgetId: string): number {
+  return activeTabByWidget.value[widgetId] ?? 0;
+}
+function activeTabChild(w: DashboardWidget): DashboardWidget | null {
+  if (w.type !== 'tab') return null;
+  const list = w.tabs ?? [];
+  return list[activeTabIndex(w.id)] ?? list[0] ?? null;
+}
+function setActiveTab(widgetId: string, index: number): void {
+  activeTabByWidget.value = { ...activeTabByWidget.value, [widgetId]: index };
+}
+// Lazy flatten: a `tab` widget contributes ONLY its active child to the
+// metrics request, so inactive tabs never hit OAP. Switching a tab changes
+// this list → the query refires for the newly-active child (and vue-query
+// keeps the prior child's response warm, so switching back is instant).
+const widgetsForQuery = computed<DashboardWidget[]>(() =>
+  (config.value?.widgets ?? []).flatMap((w) => {
+    if (w.type !== 'tab') return [w];
+    const child = activeTabChild(w);
+    return child ? [child] : [];
+  }),
+);
 // Hold the metrics fetch until the config bundle has resolved WITH widgets.
 // A resolved-but-empty config means "no dashboard for this layer/scope",
 // so we don't fire (which would otherwise make the BFF substitute its own
@@ -1214,6 +1240,15 @@ function isHidden(id: string): boolean {
             />
             <span v-else class="muted">{{ (compareMode ? compareLoading : 
(isFetching && !resultsById.has(w.id))) ? 'loading…' : 'no data' }}</span>
           </template>
+          <template v-else-if="w.type === 'tab'">
+            <TabWidget
+              :widget="w"
+              :active-index="activeTabIndex(w.id)"
+              :result="resultsById.get(activeTabChild(w)?.id ?? '')"
+              :is-fetching="isFetching && 
!resultsById.has(activeTabChild(w)?.id ?? '')"
+              @switch="setActiveTab(w.id, $event)"
+            />
+          </template>
         </div>
       </div>
     </div>
diff --git a/apps/ui/src/render/widgets/TabWidget.vue 
b/apps/ui/src/render/widgets/TabWidget.vue
new file mode 100644
index 0000000..f19de36
--- /dev/null
+++ b/apps/ui/src/render/widgets/TabWidget.vue
@@ -0,0 +1,250 @@
+<!--
+  ~ 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.
+-->
+<!--
+  Tabbed container widget. Occupies one grid slot; its `tabs` children are
+  full widgets (card / line / top / record / table) shown one at a time. The
+  active tab's child is the ONLY one queried — the host flattens it into the
+  metrics request, so an inactive tab costs nothing until you open it. The
+  active index is owned by the host (it drives that flatten); this component
+  is presentational and emits `switch` on a tab click.
+-->
+<script setup lang="ts">
+import { computed } from 'vue';
+import type { DashboardWidget, DashboardWidgetResult } from 
'@skywalking-horizon-ui/api-client';
+import TimeChart from '@/components/charts/TimeChart.vue';
+import TopList from '@/components/charts/TopList.vue';
+import RecordList from '@/render/widgets/RecordList.vue';
+import TableWidget from '@/render/widgets/TableWidget.vue';
+import { useTimeRangeStore } from '@/controls/timeRange';
+import { bucketTimeLabel, fmtMetricAs, type MetricFormat } from 
'@/utils/formatters';
+import { colorForMetric } from '@/utils/metricColor';
+
+const props = defineProps<{
+  widget: DashboardWidget;
+  activeIndex: number;
+  /** Result of the ACTIVE child only (keyed by the child's id upstream). */
+  result: DashboardWidgetResult | undefined;
+  isFetching: boolean;
+}>();
+const emit = defineEmits<{ (e: 'switch', index: number): void }>();
+
+const timeRange = useTimeRangeStore();
+
+const tabs = computed<DashboardWidget[]>(() => props.widget.tabs ?? []);
+const active = computed<DashboardWidget | null>(() => 
tabs.value[props.activeIndex] ?? tabs.value[0] ?? null);
+
+// Mirror LayerDashboardsView.widgetColor: pattern-match the metric band off
+// id / title / first expression, falling back to the catalog hue helper.
+function accentFor(w: DashboardWidget | null): string {
+  const candidates = [w?.id, w?.title, w?.expressions?.[0]].filter((c): c is 
string => !!c);
+  for (const c of candidates) {
+    const c2 = c.toLowerCase();
+    if (/(^|[^a-z])cpm([^a-z]|$)/.test(c2) || c2.includes('traffic') || 
c2.includes('rpm')) return 'var(--sw-accent)';
+    if (c2.includes('apdex')) return 'var(--sw-purple)';
+    if (c2.includes('sla') || c2.includes('success')) return 
'var(--sw-purple)';
+    if (/p\d{2,3}/.test(c2) || c2.includes('percentile') || 
c2.includes('resp_time') || c2.includes('response time') || 
c2.includes('latency')) return 'var(--sw-warn)';
+    if (c2.includes('err') || c2.includes('error') || c2.includes('failure')) 
return 'var(--sw-err)';
+  }
+  return colorForMetric(w?.id || w?.title || w?.expressions?.[0] || '');
+}
+const accent = computed(() => accentFor(active.value));
+
+// Bucket-time x labels for a line series of `len` points, across the page's
+// current range/step (same derivation the main grid uses).
+function xLabelsForLen(len: number): string[] {
+  if (len <= 0) return [];
+  const { startMs, endMs } = timeRange.range;
+  const step = timeRange.step;
+  if (len === 1) return [bucketTimeLabel(step, endMs)];
+  return Array.from({ length: len }, (_, i) =>
+    bucketTimeLabel(step, startMs + ((endMs - startMs) * i) / (len - 1)),
+  );
+}
+
+function cardText(w: DashboardWidget): string {
+  const v = props.result?.value ?? null;
+  if (v != null && w.format === 'enum' && w.valueMap) {
+    const label = w.valueMap[String(Math.round(v))];
+    if (label != null) return label;
+  }
+  return fmtMetricAs(v, w.format as MetricFormat | undefined);
+}
+
+// Chart fills the container slot minus the tab strip. Container owns the
+// grid footprint (rowSpan); children ignore their own span/rowSpan.
+const chartHeight = computed(() => Math.max(60, (props.widget.rowSpan ?? 1) * 
110 - 50 - 34));
+</script>
+
+<template>
+  <div class="tab-widget">
+    <div class="tw-strip" role="tablist">
+      <button
+        v-for="(tab, i) in tabs"
+        :key="tab.id"
+        type="button"
+        class="tw-tab"
+        :class="{ on: i === activeIndex }"
+        role="tab"
+        :aria-selected="i === activeIndex"
+        @click="emit('switch', i)"
+      >{{ tab.title || tab.id }}</button>
+    </div>
+    <div v-if="active" class="tw-body" :class="`type-${active.type}`">
+      <template v-if="result?.error">
+        <span class="muted">{{ result.error }}</span>
+      </template>
+      <template v-else-if="active.type === 'card'">
+        <div class="card-value">
+          <span class="num" :class="{ muted: result?.value == null }">
+            {{ result ? cardText(active) : (isFetching ? '…' : 
fmtMetricAs(null, active.format)) }}
+          </span>
+          <span v-if="active.unit" class="unit">{{ active.unit }}</span>
+        </div>
+      </template>
+      <template v-else-if="active.type === 'line'">
+        <TimeChart
+          v-if="result?.series?.length"
+          :series="result.series"
+          :unit="active.unit"
+          :height="chartHeight"
+          :accent="accent"
+          :format="active.format"
+          :x-labels="xLabelsForLen(result.series[0]?.data.length ?? 0)"
+        />
+        <span v-else class="muted">{{ isFetching && !result ? 'loading…' : 'no 
data' }}</span>
+      </template>
+      <template v-else-if="active.type === 'top'">
+        <TopList
+          v-if="result?.topGroups?.length"
+          :groups="result.topGroups"
+          :unit="active.unit"
+          :color="accent"
+          :title="active.title"
+        />
+        <TopList
+          v-else-if="result?.topList?.length"
+          :items="result.topList"
+          :unit="active.unit"
+          :color="accent"
+          :title="active.title"
+        />
+        <span v-else class="muted">{{ isFetching && !result ? 'loading…' : 'no 
data' }}</span>
+      </template>
+      <template v-else-if="active.type === 'record'">
+        <RecordList
+          v-if="result?.records?.length"
+          :items="result.records"
+          :unit="active.unit"
+          :color="accent"
+        />
+        <span v-else class="muted">{{ isFetching && !result ? 'loading…' : 'no 
data' }}</span>
+      </template>
+      <template v-else-if="active.type === 'table'">
+        <TableWidget
+          v-if="result?.table?.length"
+          :rows="result.table"
+          :label-top-n="active.labelTopN"
+          :headers="active.tableHeaders"
+          :show-values="active.showTableValues !== false"
+          :unit="active.unit"
+          :format="active.format"
+        />
+        <span v-else class="muted">{{ isFetching && !result ? 'loading…' : 'no 
data' }}</span>
+      </template>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tab-widget {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+.tw-strip {
+  display: flex;
+  gap: 2px;
+  padding: 0 0 4px;
+  border-bottom: 1px solid var(--sw-line);
+  margin-bottom: 4px;
+  flex: 0 0 auto;
+  overflow-x: auto;
+}
+.tw-tab {
+  padding: 3px 8px;
+  font-size: 11px;
+  font-weight: 500;
+  color: var(--sw-fg-2);
+  background: transparent;
+  border: none;
+  border-radius: 3px;
+  cursor: pointer;
+  font: inherit;
+  white-space: nowrap;
+}
+.tw-tab:hover {
+  background: var(--sw-bg-2);
+  color: var(--sw-fg-1);
+}
+.tw-tab.on {
+  background: var(--sw-bg-3);
+  color: var(--sw-fg-0);
+  font-weight: 600;
+}
+.tw-body {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+}
+.tw-body.type-card {
+  align-items: center;
+  justify-content: center;
+}
+.tw-body :deep(.top-list) {
+  flex: 1;
+  min-height: 0;
+}
+.tw-body :deep(.top-list .rows) {
+  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;
+}
+</style>
diff --git a/docs/customization/layer-templates.md 
b/docs/customization/layer-templates.md
index b727b28..e280132 100644
--- a/docs/customization/layer-templates.md
+++ b/docs/customization/layer-templates.md
@@ -191,8 +191,9 @@ A layer without an explicit `instance` widget set will 
reuse `service` widgets o
 | `id` | Unique widget id within the dashboard. |
 | `title` | Widget title shown in the card header. |
 | `tip` | Optional hover hint. |
-| `type` | Widget kind, usually `card`, `line`, `top`, `record`, or `table`. |
-| `expressions[]` | MQE expressions to run. |
+| `type` | Widget kind, usually `card`, `line`, `top`, `record`, `table`, or 
`tab` (a container holding several widgets as switchable tabs — see [Tab 
widgets](#tab-widgets)). |
+| `tabs[]` | `tab` widgets only: the child widgets, one per tab. Each child is 
a full widget object. |
+| `expressions[]` | MQE expressions to run. A `tab` container has none of its 
own. |
 | `expressionLabels[]` | Tab labels for `top`, legend labels for `line`. |
 | `expressionUnits[]` | Per-expression unit override. |
 | `expressionAxes[]` | `0` for left axis, `1` for right axis on dual-axis line 
charts. |
@@ -226,6 +227,31 @@ The widget type **must match the MQE shape**:
 
 A `line` widget with a scalar-collapsed MQE renders a one-point chart and 
confuses operators. The widget editor warns; the schema does not enforce.
 
+### Tab widgets
+
+A `tab` widget packs several widgets into one grid slot, shown as switchable 
tabs. Use it when several related views — traffic / latency / Apdex, or 
success-rate / error-count — belong together but you don't want to spend three 
slots on them.
+
+Each tab is a **full widget** with its own type (`card` / `line` / `top` / 
`table`), MQE expressions, unit, format, and visibility. The tab's label is its 
`title`. Only the **active** tab is queried — switching to a tab loads its data 
on demand and then keeps it warm, so an unopened tab costs nothing and flipping 
back is instant. A tab cannot contain another tab (one level deep).
+
+To author one in the admin: add a widget, set its **Type** to `tab`, then use 
the **Tabs** chip strip to add, rename, reorder, or remove tabs — selecting a 
chip edits that tab as its own widget; the **⚙ Container** chip edits the 
container's title and grid size (the container owns the slot; a child's own 
`span` / `rowSpan` are ignored).
+
+The stored shape — a container with empty `expressions` and a `tabs[]` array 
of child widgets:
+
+```json
+{
+  "id": "svc_signals",
+  "title": "Service signals",
+  "type": "tab",
+  "span": 6,
+  "rowSpan": 3,
+  "tabs": [
+    { "id": "sig_traffic", "title": "Traffic", "type": "line", "unit": "rpm", 
"expressions": ["service_cpm"] },
+    { "id": "sig_latency", "title": "Latency", "type": "line", "unit": "ms", 
"expressions": ["service_resp_time"] },
+    { "id": "sig_apdex", "title": "Apdex", "type": "card", "format": 
"decimal", "expressions": ["service_apdex/10000"] }
+  ]
+}
+```
+
 ## `topology`
 
 Config for the **Topology** map (the service-map view): which MQE metrics 
decorate each service node and each service-to-service call edge — and, 
optionally, the **instance map** drill-down. Edited in the admin under the 
layer's **Topology** scope (node-metric / server-edge / client-edge editors). 
Without a block, a sensible default metric set is used.
diff --git a/packages/api-client/src/dashboard.ts 
b/packages/api-client/src/dashboard.ts
index 0b6419c..420ad52 100644
--- a/packages/api-client/src/dashboard.ts
+++ b/packages/api-client/src/dashboard.ts
@@ -49,8 +49,11 @@
  *            graph for label-dimensioned meters (pod phase per service,
  *            node condition, deployment replicas, …) that a scalar card
  *            or a time-series line cannot represent.
+ *   tab    — a container: one grid slot holding several full widgets (any
+ *            of the above) shown as switchable tabs. It carries no MQE of
+ *            its own (see `tabs`); only the active tab's child is queried.
  */
-export type DashboardWidgetType = 'card' | 'line' | 'top' | 'record' | 'table';
+export type DashboardWidgetType = 'card' | 'line' | 'top' | 'record' | 'table' 
| 'tab';
 
 /**
  * Structured widget visibility predicate. When set on a widget, the BFF
@@ -122,8 +125,20 @@ export interface DashboardWidget {
   type: DashboardWidgetType;
   /** One or more MQE expressions. `card` collapses to a scalar (avg);
    *  `line` renders one labeled series per expression; `top` treats
-   *  every expression as a switchable view (see `expressionLabels`). */
+   *  every expression as a switchable view (see `expressionLabels`). A
+   *  `tab` container has none of its own — it carries an empty array and
+   *  defers to its `tabs` children. */
   expressions: string[];
+  /**
+   * `tab` container ONLY: the child widgets, one per tab. Each child is a
+   * full {@link DashboardWidget} (card / line / top / table / record) with
+   * its own `type` / `expressions` / `unit` / `visibleWhen`; the tab label
+   * is the child's `title`. One level deep — a child must NOT itself be a
+   * `tab`. A child's `span` / `rowSpan` are ignored: the container owns the
+   * grid slot and the active child fills it. Only the active tab's child is
+   * queried (lazy); switching tabs fetches the newly-active child.
+   */
+  tabs?: DashboardWidget[];
   /**
    * Optional human-readable label per entry in `expressions`. For
    * `top` widgets these drive the in-widget switcher tabs (e.g.

Reply via email to