From 76bd5dd822aff4e87f2efb691335fb446caefccc Mon Sep 17 00:00:00 2001
From: Omkar Kesarkhane <127078473+omkarK06@users.noreply.github.com>
Date: Fri, 12 Jun 2026 13:15:30 +0530
Subject: [PATCH] fix: added skeleton loading for traces table (#12593)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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.
---
web/src/components/TenstackTable.spec.ts | 23 +-
web/src/components/TenstackTable.vue | 157 +++++++++++-
.../traces/ServiceGraphNodeSidePanel.vue | 228 ++++++++----------
web/src/plugins/traces/ServicesCatalog.vue | 29 ---
.../components/TracesSearchResultList.spec.ts | 11 +-
.../components/TracesSearchResultList.vue | 36 ---
6 files changed, 273 insertions(+), 211 deletions(-)
diff --git a/web/src/components/TenstackTable.spec.ts b/web/src/components/TenstackTable.spec.ts
index b6e313b6ab..ffdfe78a65 100644
--- a/web/src/components/TenstackTable.spec.ts
+++ b/web/src/components/TenstackTable.spec.ts
@@ -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: '
Loading…
' },
+ );
+ 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", () => {
diff --git a/web/src/components/TenstackTable.vue b/web/src/components/TenstackTable.vue
index 3da93c5e1b..4c5292d400 100644
--- a/web/src/components/TenstackTable.vue
+++ b/web/src/components/TenstackTable.vue
@@ -304,8 +304,13 @@ along with this program. If not, see .
-
-
+
+
| .
opacity: 0.7,
}"
>
-
-
-
- {{ t("confirmDialog.loading") }}
-
-
+
-
+
|
|
@@ -392,6 +390,58 @@ along with this program. If not, see .
+
+
+
+
+
+
+ |
+
+ |
+
+
+
+
+ |
+
+
+
+
@@ -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 => {
+ 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 {
diff --git a/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
index e5a46d430c..f8ad55f27d 100644
--- a/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
+++ b/web/src/plugins/traces/ServiceGraphNodeSidePanel.vue
@@ -242,26 +242,15 @@ along with this program. If not, see .
class="tw:p-0! panel-section tw:mb-0!"
data-test="service-graph-side-panel-recent-operations"
>
-
-
- Loading operations...
+ No operations found
-
-
-
- No operations found
-
-
@@ -270,7 +259,7 @@ along with this program. If not, see .
: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 .
-
-
+
@@ -370,121 +358,111 @@ along with this program. If not, see .
:data-test="`service-graph-side-panel-${cfg.id}`"
>
-
- Loading {{ cfg.label.toLowerCase() }}...
+ No {{ cfg.label.toLowerCase() }} data found
-
-
+
+ navigateToTraces({
+ resourceFilter: cfg.fields
+ ? { fields: cfg.fields, value: row[cfg.colId] }
+ : { field: cfg.groupField, value: row[cfg.colId] },
+ })
+ "
>
- No {{ cfg.label.toLowerCase() }} data found
-
-
-
+
+
-
-
-
-
-
-
-
- {{ item.errors }}
-
-
- {{ formatOperationLatency(item.p99) }}
-
-
- {{ formatOperationLatency(item.p95) }}
-
-
- {{ formatOperationLatency(item.p75) }}
-
-
-
- No {{ cfg.label.toLowerCase() }} data found
-
-
-
-
-
+ "
+ >
+
+
+
+
+
+ {{ item.errors }}
+
+
+ {{ formatOperationLatency(item.p99) }}
+
+
+ {{ formatOperationLatency(item.p95) }}
+
+
+ {{ formatOperationLatency(item.p75) }}
+
+
+
+ No {{ cfg.label.toLowerCase() }} data found
+
+
+
+
diff --git a/web/src/plugins/traces/ServicesCatalog.vue b/web/src/plugins/traces/ServicesCatalog.vue
index 0c937244ee..8001944ae1 100644
--- a/web/src/plugins/traces/ServicesCatalog.vue
+++ b/web/src/plugins/traces/ServicesCatalog.vue
@@ -327,35 +327,6 @@ along with this program. If not, see .
-
-
-
-
-
- {{ t("traces.servicesCatalog.loading") }}
-
-
-
-
-
-
-
-
-
- {{ t("traces.servicesCatalog.loading") }}
-
-
-
-
({
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: `
-
-
+
Skeleton loading...
+
+
@@ -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);
});
diff --git a/web/src/plugins/traces/components/TracesSearchResultList.vue b/web/src/plugins/traces/components/TracesSearchResultList.vue
index dd219f2906..f38cf76049 100644
--- a/web/src/plugins/traces/components/TracesSearchResultList.vue
+++ b/web/src/plugins/traces/components/TracesSearchResultList.vue
@@ -81,42 +81,6 @@ along with this program. If not, see .
/>
-
-
-
-
- {{ t("traces.fetchingTraces") }}
-
-
-
-
-
-
-
- {{ t("traces.fetchingTraces") }}
-
-
-