fix: added skeleton loading for traces table (#12593)

## Why

The custom spinner-based loading states were inconsistent across traces
views anddid not align with the logs TenstackTable skeleton pattern
already established inthe codebase. The new skeleton provides a unified,
low-friction loading experience — shimmer pills at column-aligned
positions that mirror real cell widths — and removes the need for each
consumer to write its own loading template.
This commit is contained in:
Omkar Kesarkhane
2026-06-12 07:45:30 +00:00
committed by GitHub
parent 4ecc066ac1
commit 76bd5dd822
6 changed files with 273 additions and 211 deletions
+19 -4
View File
@@ -325,13 +325,28 @@ describe("TenstackTable", () => {
// ── Loading state ─────────────────────────────────────────────────────────
describe("loading state", () => {
it("should show the default loading indicator when loading=true and no rows", () => {
it("should show the skeleton loading body when loading=true and no rows", () => {
wrapper = mountTable({ rows: [], loading: true });
expect(
wrapper.find('[data-test="tenstack-table-loading-indicator"]').exists(),
wrapper.find('[data-test="tenstack-table-skeleton-body"]').exists(),
).toBe(true);
});
it("should not show the skeleton body when a custom loading slot is provided", () => {
wrapper = mountTable(
{ rows: [], loading: true },
{ loading: '<div data-test="custom-loading-slot">Loading…</div>' },
);
expect(
wrapper.find('[data-test="tenstack-table-skeleton-body"]').exists(),
).toBe(false);
});
it("should show the skeleton body when loading=true (follows O2 pattern)", () => {
wrapper = mountTable({ loading: true });
expect(
wrapper.find('[data-test="tenstack-table-skeleton-body"]').exists(),
).toBe(true);
// i18n key is rendered as-is by the mock
expect(wrapper.html()).toContain("confirmDialog.loading");
});
it("should render a custom loading slot when loading=true and rows is empty", () => {
+145 -12
View File
@@ -304,8 +304,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</th>
</vue-draggable>
<!-- Loading row: shown when there are no rows yet (initial fetch) -->
<tr v-if="loading && tableRows.length === 0" class="tw:w-full">
<!-- Loading row: only rendered when the parent supplies a custom
#loading slot. With no slot, the shimmer skeleton <tbody> below
takes over (initial fetch). -->
<tr
v-if="loading && tableRows.length === 0 && $slots.loading"
class="tw:w-full"
>
<td
:colspan="columnOrder.length"
class="tw:font-bold"
@@ -315,18 +320,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
opacity: 0.7,
}"
>
<slot name="loading">
<div
class="tw:text-sm tw:font-medium text-weight-bold tw:flex tw:items-center"
>
<OSpinner size="xs" data-test="tenstack-table-loading-indicator" />
{{ t("confirmDialog.loading") }}
</div>
</slot>
<slot name="loading" />
</td>
</tr>
<!-- Loading banner: shown above rows while a new page is fetching -->
<tr v-if="loading && tableRows.length > 0" class="tw:w-full">
<tr v-if="loading && tableRows.length > 0 && $slots['loading-banner']" class="tw:w-full">
<td :colspan="columnOrder.length">
<slot name="loading-banner" />
</td>
@@ -392,6 +390,58 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</td>
</tr>
</thead>
<!-- Skeleton loading body — shimmer placeholder shown on initial fetch
when the parent does not provide a custom #loading slot. Mirrors the
logs TenstackTable pattern. -->
<tbody
v-if="loading && !$slots.loading"
data-test="tenstack-table-skeleton-body"
aria-busy="true"
aria-label="Loading"
>
<!-- Rows use tw:flex to align with the real virtual/data rows. -->
<tr
v-for="r in SKEL_ROW_COUNT"
:key="`skel-${r}`"
class="o2-skel-row tw:flex tw:items-center tw:w-full"
:style="{ animationDelay: `${(r - 1) * 40}ms` }"
>
<!-- No columns yet (first paint) — full-width shimmer bar -->
<td
v-if="!headers?.length"
class="tw:w-full tw:px-4 tw:overflow-hidden"
>
<span
class="o2-skel-pill tw:inline-block tw:h-3 tw:rounded-md"
:style="{ width: `${skelCellWidth(r - 1, 0)}%` }"
aria-hidden="true"
/>
</td>
<!-- Columns available — per-column aligned shimmer pills -->
<template v-else>
<td
v-for="(header, c) in headers"
:key="header.id"
class="tw:px-2 tw:overflow-hidden"
:class="c === 0 ? 'tw:pl-4' : ''"
:style="skelTdStyle(header, c)"
>
<span
class="o2-skel-pill tw:inline-block tw:h-3 tw:rounded-md"
:style="{
width:
c === 0
? `${SKEL_TIMESTAMP_PX}px`
: `${skelCellWidth(r - 1, c)}%`,
}"
aria-hidden="true"
/>
</td>
</template>
</tr>
</tbody>
<!-- tw:relative is only needed for virtual-scroll absolute rows (logs/traces).
In dashboard mode (regular DOM rows) it must be absent so that
position:sticky on <thead> works correctly. -->
@@ -1063,7 +1113,6 @@ import {
PIVOT_TABLE_ROW_KEY_SEPARATOR,
} from "@/utils/dashboard/constants";
import JsonFieldRenderer from "@/components/dashboards/panels/JsonFieldRenderer.vue";
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
export interface StreamField {
name: string;
@@ -1837,6 +1886,29 @@ const headerGroups = computed(() => table?.getHeaderGroups()[0]);
const headers = computed(() => headerGroups.value.headers);
// ── Skeleton loading helpers — mirrors the logs TenstackTable shimmer pattern ──
const SKEL_ROW_COUNT = 30;
const SKEL_BASE_WIDTHS = [55, 70, 60, 45, 65, 50, 75, 40, 58, 68, 48, 62];
const SKEL_JITTER = [0, 6, -4, 3, -2, 5, -3, 2, -5, 4, -1, 6];
// First column (timestamp): pill sized to "2026-06-02 12:09:00.349"
// (23 chars × 7.2 px/char in monospace 12 px).
const SKEL_TIMESTAMP_PX = Math.round("2026-06-02 12:09:00.349".length * 7.2);
const skelCellWidth = (r: number, c: number): number => {
const base = SKEL_BASE_WIDTHS[c % SKEL_BASE_WIDTHS.length] ?? 60;
const jit = SKEL_JITTER[(r + c) % SKEL_JITTER.length] ?? 0;
return Math.max(25, Math.min(85, base + jit));
};
// Mirror the exact width/flex logic of the real cells so skeleton columns align.
// Source column (width:'auto' in real rows) → flex:1 to fill remaining space.
// All other columns: fixed width from the CSS variable (same as real rows).
const skelTdStyle = (header: any, c: number): Record<string, string> => {
const colId = header.column.id;
const isStretchSource = colId === "source" && !header.column.getCanResize();
if (isStretchSource) return { flex: "1 1 0", minWidth: "0" };
const w = `calc(var(--col-${sanitizeCssId(colId)}-size) * 1px)`;
return { width: w, minWidth: w, flexShrink: "0" };
};
watch(
() => headers.value,
(newVal) => {
@@ -2372,6 +2444,67 @@ defineExpose({
}
}
// ── Loading skeleton ─────────────────────────────────────────────
.o2-skel-row {
opacity: 0;
animation: o2-skel-row-in 320ms ease-out forwards;
border-bottom: 1px solid var(--o2-tag-grey-1);
height: 1.75rem;
}
.o2-skel-pill {
// Light mode — grey-100 shimmer (matches logs/histogram skeletons)
background: linear-gradient(
90deg,
var(--color-grey-100) 0%,
rgba(255, 255, 255, 0.65) 50%,
var(--color-grey-100) 100%
);
background-size: 200% 100%;
animation: o2-skel-shimmer 1.5s ease-in-out infinite;
// Dark mode — grey-600 shimmer
.body--dark & {
background: linear-gradient(
90deg,
var(--color-grey-600) 0%,
rgba(255, 255, 255, 0.03) 50%,
var(--color-grey-600) 100%
);
background-size: 200% 100%;
}
}
@keyframes o2-skel-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes o2-skel-row-in {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.o2-skel-row {
opacity: 1;
animation: none;
}
.o2-skel-pill {
animation: none;
}
}
// Copy button — only visible on cell hover, sized to match text so it
// doesn't inflate the row height.
.copy-cell-td {
@@ -242,26 +242,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="tw:p-0! panel-section tw:mb-0!"
data-test="service-graph-side-panel-recent-operations"
>
<!-- Loading State -->
<div
v-if="loadingOperations"
class="tw:flex tw:items-center tw:gap-2 tw:py-3 tw:text-sm"
v-if="recentOperations.length === 0 && !loadingOperations"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
<OSpinner size="xs" data-test="service-graph-operations-loading-indicator" />
<span>Loading operations...</span>
No operations found
</div>
<template v-else>
<div
v-if="recentOperations.length === 0"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No operations found
</div>
<div
v-else
<div
v-else-if="recentOperations.length > 0 || loadingOperations"
class="tw:overflow-hidden tw:rounded"
data-test="service-graph-side-panel-operations-table"
>
@@ -270,7 +259,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:rows="sortedOperationsTableRows"
:sort-by="sortBy"
:sort-order="sortOrder"
:loading="false"
:loading="loadingOperations"
:default-columns="false"
:enable-column-reorder="false"
:enable-row-expand="false"
@@ -356,8 +345,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
</TenstackTable>
</div>
</template>
</div>
</OTabPanel>
<!-- Nodes Tab -->
@@ -370,121 +358,111 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data-test="`service-graph-side-panel-${cfg.id}`"
>
<div
v-if="resourceTabLoading[cfg.id]"
class="tw:flex tw:items-center tw:gap-2 tw:py-3 tw:text-sm"
v-if="!resourceTabData[cfg.id]?.length && !resourceTabLoading[cfg.id]"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
<OSpinner size="xs" data-test="service-graph-resource-loading-indicator" />
<span>Loading {{ cfg.label.toLowerCase() }}...</span>
No {{ cfg.label.toLowerCase() }} data found
</div>
<template v-else>
<div
v-if="!resourceTabData[cfg.id]?.length"
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
<div
v-else-if="resourceTabData[cfg.id]?.length > 0 || resourceTabLoading[cfg.id]"
class="tw:overflow-hidden tw:rounded"
:data-test="`service-graph-side-panel-${cfg.id}-table`"
>
<TenstackTable
:columns="buildEntityTableColumns(cfg.colId, cfg.colLabel)"
:rows="sortResourceRows(buildResourceTableRows(cfg))"
:sort-by="sortBy"
:sort-order="sortOrder"
:loading="resourceTabLoading[cfg.id]"
:default-columns="false"
:enable-column-reorder="false"
:enable-row-expand="false"
:enable-text-highlight="false"
:enable-status-bar="false"
:enable-ai-context-button="false"
:row-height="28"
@sort-change="handleSortChange"
@click:data-row="
(row: any) =>
navigateToTraces({
resourceFilter: cfg.fields
? { fields: cfg.fields, value: row[cfg.colId] }
: { field: cfg.groupField, value: row[cfg.colId] },
})
"
>
No {{ cfg.label.toLowerCase() }} data found
</div>
<div
v-else
class="tw:overflow-hidden tw:rounded"
:data-test="`service-graph-side-panel-${cfg.id}-table`"
>
<TenstackTable
:columns="buildEntityTableColumns(cfg.colId, cfg.colLabel)"
:rows="sortResourceRows(buildResourceTableRows(cfg))"
:sort-by="sortBy"
:sort-order="sortOrder"
:loading="false"
:default-columns="false"
:enable-column-reorder="false"
:enable-row-expand="false"
:enable-text-highlight="false"
:enable-status-bar="false"
:enable-ai-context-button="false"
:row-height="28"
@sort-change="handleSortChange"
@click:data-row="
(row: any) =>
<template #cell-actions="{ row, column, active }">
<OButton
v-if="active"
variant="ghost"
size="icon"
class="tw:ml-1 tw:absolute! tw:right-2!"
:data-test="`service-graph-side-panel-${cfg.id}-view-traces-btn`"
@click.stop="
navigateToTraces({
resourceFilter: cfg.fields
? { fields: cfg.fields, value: row[cfg.colId] }
: { field: cfg.groupField, value: row[cfg.colId] },
errorsOnly: column.id === 'errors',
minDurationMicros: isDurationColumn(column.id) ? row[column.id] : undefined
})
"
>
<template #cell-actions="{ row, column, active }">
<OButton
v-if="active"
variant="ghost"
size="icon"
class="tw:ml-1 tw:absolute! tw:right-2!"
:data-test="`service-graph-side-panel-${cfg.id}-view-traces-btn`"
@click.stop="
navigateToTraces({
resourceFilter: cfg.fields
? { fields: cfg.fields, value: row[cfg.colId] }
: { field: cfg.groupField, value: row[cfg.colId] },
errorsOnly: column.id === 'errors',
minDurationMicros: isDurationColumn(column.id) ? row[column.id] : undefined
})
"
>
<OIcon name="search" size="xs" />
<OTooltip content="View in Traces" />
</OButton>
</template>
<template #cell-errors="{ item }">
<span
:class="
item.errors > 0
? 'tw:text-[var(--q-negative)] tw:font-semibold'
: ''
"
>{{ item.errors }}</span
>
</template>
<template #cell-p99="{ item }">
<span
:class="
item.p99 > 0
? 'tw:text-[var(--o2-latency-p99)]'
: ''
"
>{{ formatOperationLatency(item.p99) }}</span
>
</template>
<template #cell-p95="{ item }">
<span
:class="
item.p95 > 0
? 'tw:text-[var(--o2-latency-p95)]'
: ''
"
>{{ formatOperationLatency(item.p95) }}</span
>
</template>
<template #cell-p75="{ item }">
<span
:class="
item.p75 > 0
? 'tw:text-[var(--o2-latency-p75)]'
: ''
"
>{{ formatOperationLatency(item.p75) }}</span
>
</template>
<template #empty>
<div
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No {{ cfg.label.toLowerCase() }} data found
</div>
</template>
</TenstackTable>
</div>
</template>
"
>
<OIcon name="search" size="xs" />
<OTooltip content="View in Traces" />
</OButton>
</template>
<template #cell-errors="{ item }">
<span
:class="
item.errors > 0
? 'tw:text-[var(--q-negative)] tw:font-semibold'
: ''
"
>{{ item.errors }}</span
>
</template>
<template #cell-p99="{ item }">
<span
:class="
item.p99 > 0
? 'tw:text-[var(--o2-latency-p99)]'
: ''
"
>{{ formatOperationLatency(item.p99) }}</span
>
</template>
<template #cell-p95="{ item }">
<span
:class="
item.p95 > 0
? 'tw:text-[var(--o2-latency-p95)]'
: ''
"
>{{ formatOperationLatency(item.p95) }}</span
>
</template>
<template #cell-p75="{ item }">
<span
:class="
item.p75 > 0
? 'tw:text-[var(--o2-latency-p75)]'
: ''
"
>{{ formatOperationLatency(item.p75) }}</span
>
</template>
<template #empty>
<div
class="tw:text-xs tw:italic tw:py-2 tw:text-center"
style="color: var(--o2-text-secondary)"
>
No {{ cfg.label.toLowerCase() }} data found
</div>
</template>
</TenstackTable>
</div>
</OTabPanel>
<!-- Metrics Tab -->
@@ -327,35 +327,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</span>
</template>
<!-- Loading banner: shown above rows while a next page is fetching -->
<template #loading-banner>
<div
class="tw:flex tw:flex-nowrap tw:items-center tw:px-2 tw:min-w-max tw:min-h-[3.25rem] tw:bg-[var(--o2-card-bg)] tw:border-b tw:border-[var(--o2-border-2)]!"
>
<OSpinner size="xs" class="tw:mx-[0.25rem]" />
<span
class="tw:tracking-[0.03rem] tw:text-[0.85rem] tw:text-[var(--o2-text-1)] tw:font-bold"
>
{{ t("traces.servicesCatalog.loading") }}
</span>
</div>
</template>
<!-- Loading row: shown when no rows exist yet (first fetch) -->
<template #loading>
<div
data-test="services-catalog-loading"
class="tw:flex tw:flex-nowrap tw:items-center tw:px-2 tw:min-w-max tw:min-h-[3.25rem] tw:bg-[var(--o2-card-bg)] tw:border-b tw:border-[var(--o2-border-2)]!"
>
<OSpinner size="xs" class="tw:mr-[0.25rem]" />
<span
class="tw:tracking-[0.03rem] tw:text-[0.85rem] tw:text-[var(--o2-text-1)] tw:font-bold"
>
{{ t("traces.servicesCatalog.loading") }}
</span>
</div>
</template>
<!-- Cell actions overlay -->
<template #cell-actions="{ row, column, active }">
<CellActions
@@ -131,12 +131,13 @@ vi.mock("@/components/TenstackTable.vue", () => ({
setup() {
return { cellActionsColumn: cellActionsColumnRef };
},
// Mirror the real slot behaviour used by TracesSearchResultList.
// Mirror the real skeleton behaviour used by TenstackTable.
// Also render cell slots for the first row so slot-split tests can assert on rendered output.
template: `
<div data-test="stub-traces-table">
<slot v-if="loading && (!rows || rows.length === 0)" name="loading" />
<slot v-else name="empty" />
<div v-if="loading && (!$slots.loading)" data-test="tenstack-table-skeleton-body">Skeleton loading...</div>
<slot v-if="loading && (!rows || rows.length === 0) && $slots.loading" name="loading" />
<slot v-else-if="!loading" name="empty" />
<template v-if="rows && rows.length">
<div data-test="stub-cell-span_status"><slot name="cell-span_status" :item="rows[0]" /></div>
<div data-test="stub-cell-status"><slot name="cell-status" :item="rows[0]" /></div>
@@ -214,10 +215,10 @@ describe("TracesSearchResultList", () => {
// ─── Loading state ────────────────────────────────────────────────────────
describe("loading state", () => {
it("shows a spinner while loading", () => {
it("shows skeleton while loading", () => {
wrapper = mount_({ hits: [], loading: true });
expect(
wrapper.find('[data-test="traces-search-result-loading-indicator"]').exists(),
wrapper.find('[data-test="tenstack-table-skeleton-body"]').exists(),
).toBe(true);
});
@@ -81,42 +81,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/>
</template>
<!-- Loading banner: shown above rows while a new page is fetching -->
<template #loading-banner>
<div
data-test="traces-table-loading-banner-row"
class="tw:flex tw:flex-nowrap tw:items-center tw:px-2 tw:min-w-max tw:min-h-[3.25rem] tw:bg-[var(--o2-card-bg)] tw:border-b tw:border-[var(--o2-border-2)]!"
>
<OSpinner
size="xs"
class="tw:mx-[0.25rem]"
data-test="traces-search-result-loading-indicator"
/>
<span
class="tw:tracking-[0.03rem] tw:text-[0.85rem] tw:text-[var(--o2-text-1)] tw:font-bold"
>{{ t("traces.fetchingTraces") }}</span
>
</div>
</template>
<!-- Loading row: shown when no rows exist yet (first fetch) -->
<template #loading>
<div
data-test="traces-table-loading-row"
class="tw:flex tw:flex-nowrap tw:items-center tw:px-2 tw:min-w-max tw:min-h-[3.25rem] tw:bg-[var(--o2-card-bg)] tw:border-b tw:border-[var(--o2-border-2)]!"
>
<OSpinner
size="xs"
class="tw:mr-[0.25rem]"
data-test="traces-search-result-loading-indicator"
/>
<span
class="tw:tracking-[0.03rem] tw:text-[0.85rem] tw:text-[var(--o2-text-1)] tw:font-bold"
>{{ t("traces.fetchingTraces") }}</span
>
</div>
</template>
<template
#[`cell-${store.state.zoConfig.timestamp_column}`]="{ cell }"
>