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">></option> - <option value="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">></option> + <option value="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.
