-
+
+
{{ webinarData.tag }}: {{ webinarData.title }}
{{ formattedDate }}
@@ -37,7 +38,7 @@ along with this program. If not, see .
:href="webinarData.primaryButton.link"
target="_blank"
rel="noopener noreferrer"
- class="webinar-top-bar-link text-compact text-promo-webinar-link hover:text-promo-webinar-link-hover font-bold whitespace-nowrap underline"
+ class="webinar-top-bar-link text-promo-webinar-link hover:text-promo-webinar-link-hover font-bold whitespace-nowrap underline"
data-test="webinar-top-bar-register-link"
>
{{ webinarData.primaryButton.text }}
@@ -58,7 +59,7 @@ along with this program. If not, see .
{{ t("components.webinarBanner.dismiss") }}
- {
expect(wrapper.vm.editingTabId).toBe("tab2");
});
+ // A 0 min track (`minmax(0,max-content)`) lets the editing tab shrink to 0px
+ // once the strip overflows, hiding the input. jsdom has no layout engine, so
+ // assert the declaration rather than the width.
+ it("should size the rename field with a max-content track that cannot collapse", async () => {
+ wrapper = createWrapper();
+
+ wrapper.vm.startRename({ tabId: "tab2", name: "Second Tab" });
+ await flushPromises();
+
+ const grid = wrapper.find('[data-test="dashboard-tab-tab2-rename-input"]').element
+ .parentElement as HTMLElement;
+ expect(grid.className).toContain("grid-cols-[max-content]");
+ expect(grid.className).not.toContain("minmax(0");
+ });
+
it("should persist a changed name via editTab and emit refresh", async () => {
wrapper = createWrapper();
diff --git a/web/src/components/dashboards/tabs/TabList.vue b/web/src/components/dashboards/tabs/TabList.vue
index 5f96159ce1..ca047bc39a 100644
--- a/web/src/components/dashboards/tabs/TabList.vue
+++ b/web/src/components/dashboards/tabs/TabList.vue
@@ -46,11 +46,10 @@ along with this program. If not, see
.
-
+ the sizer alone drives the max-content column. Keep that track a bare
+ `max-content`: a 0 min (`minmax(0,…)`) collapses it to 0px once the
+ tab strip overflows. -->
+
useDynamicRowHeight.value,
+ // A delegated scroller can contain a histogram or other content before this
+ // table. Keep its raw offset stable when wrapped rows are remeasured so a
+ // query refresh cannot move the owning page's scrollbar.
+ preserveScrollOffsetOnRowResize: () => !!props.scrollEl && useDynamicRowHeight.value,
});
const isVirtual = computed(() => props.virtualScroll && displayRows.value.length > 0);
diff --git a/web/src/lib/core/Table/OTable.wrapMeasure.spec.ts b/web/src/lib/core/Table/OTable.wrapMeasure.spec.ts
index 401af0bff8..dee3b1a675 100644
--- a/web/src/lib/core/Table/OTable.wrapMeasure.spec.ts
+++ b/web/src/lib/core/Table/OTable.wrapMeasure.spec.ts
@@ -14,24 +14,26 @@ beforeAll(() => {
// measurement wiring never renders — stub it with a fixed window of rows.
const measureSpy = vi.fn();
const measureElementSpy = vi.fn();
+const virtualizerMock = {
+ getVirtualItems: () =>
+ Array.from({ length: 5 }, (_, i) => ({
+ index: i,
+ key: i,
+ start: i * 20,
+ end: (i + 1) * 20,
+ size: 20,
+ lane: 0,
+ })),
+ getTotalSize: () => 100,
+ measure: measureSpy,
+ measureElement: measureElementSpy,
+ scrollToIndex: vi.fn(),
+ shouldAdjustScrollPositionOnItemSizeChange: undefined as
+ ((...args: any[]) => boolean) | undefined,
+};
vi.mock("@tanstack/vue-virtual", () => ({
- useVirtualizer: () =>
- ref({
- getVirtualItems: () =>
- Array.from({ length: 5 }, (_, i) => ({
- index: i,
- key: i,
- start: i * 20,
- end: (i + 1) * 20,
- size: 20,
- lane: 0,
- })),
- getTotalSize: () => 100,
- measure: measureSpy,
- measureElement: measureElementSpy,
- scrollToIndex: vi.fn(),
- }),
+ useVirtualizer: () => ref(virtualizerMock),
}));
import OTable from "./OTable.vue";
@@ -69,6 +71,7 @@ describe("OTable virtual measurement", () => {
beforeEach(() => {
measureSpy.mockClear();
measureElementSpy.mockClear();
+ virtualizerMock.shouldAdjustScrollPositionOnItemSizeChange = undefined;
});
afterEach(() => {
@@ -103,4 +106,23 @@ describe("OTable virtual measurement", () => {
wrapper = mountTable({ wrap: false, expansion: "none" });
expect(measureSpy).not.toHaveBeenCalled();
});
+
+ it("should keep a delegated scroller stable after wrap is enabled", async () => {
+ wrapper = mountTable({
+ wrap: false,
+ scrollEl: document.createElement("div"),
+ });
+ expect(virtualizerMock.shouldAdjustScrollPositionOnItemSizeChange).toBeUndefined();
+
+ await wrapper.setProps({ wrap: true });
+
+ const shouldAdjust = virtualizerMock.shouldAdjustScrollPositionOnItemSizeChange;
+ expect(shouldAdjust).toBeTypeOf("function");
+ expect(shouldAdjust?.()).toBe(false);
+ });
+
+ it("should retain the virtualizer's default anchoring for an internal scroller", () => {
+ wrapper = mountTable({ wrap: true });
+ expect(virtualizerMock.shouldAdjustScrollPositionOnItemSizeChange).toBeUndefined();
+ });
});
diff --git a/web/src/lib/core/Table/composables/useTableVirtualization.ts b/web/src/lib/core/Table/composables/useTableVirtualization.ts
index 40bf8ba6cd..846b515192 100644
--- a/web/src/lib/core/Table/composables/useTableVirtualization.ts
+++ b/web/src/lib/core/Table/composables/useTableVirtualization.ts
@@ -1,6 +1,6 @@
// Copyright 2026 OpenObserve Inc.
-import { computed, toValue, type Ref, type MaybeRefOrGetter } from "vue";
+import { computed, toValue, watchEffect, type Ref, type MaybeRefOrGetter } from "vue";
import { useVirtualizer } from "@tanstack/vue-virtual";
import type { Row } from "@tanstack/vue-table";
@@ -25,6 +25,13 @@ export interface VirtualizationOptions {
* reactive `wrap` state.
*/
dynamicRowHeight?: MaybeRefOrGetter;
+ /**
+ * Keep the scroll element's raw offset stable while measured row heights
+ * change. Delegated tables need this because their scroll element can also
+ * contain content above the virtual list; applying the list's measurement
+ * deltas to that shared element makes it jump during result refreshes.
+ */
+ preserveScrollOffsetOnRowResize?: MaybeRefOrGetter;
}
/**
@@ -42,6 +49,7 @@ export function useTableVirtualization(options: VirtualizationOptions) {
expandedRowHeights,
overscan = 100,
dynamicRowHeight,
+ preserveScrollOffsetOnRowResize,
} = options;
const isFirefox = computed(() => {
@@ -94,6 +102,20 @@ export function useTableVirtualization(options: VirtualizationOptions) {
});
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions);
+
+ // TanStack normally compensates scrollTop when a measured item above the
+ // viewport differs from its estimate. That is useful for a self-contained
+ // list, but wrong for a delegated scroller that also owns a histogram or
+ // other content above the table: wrapped rows are remeasured when query data
+ // changes and their deltas otherwise push the whole Logs view downward.
+ watchEffect(() => {
+ rowVirtualizer.value.shouldAdjustScrollPositionOnItemSizeChange = toValue(
+ preserveScrollOffsetOnRowResize,
+ )
+ ? () => false
+ : undefined;
+ });
+
const virtualRows = computed(() => rowVirtualizer.value.getVirtualItems());
const totalSize = computed(() => rowVirtualizer.value.getTotalSize() + 24);
diff --git a/web/src/lib/feedback/Banner/OBanner.vue b/web/src/lib/feedback/Banner/OBanner.vue
index ca6eed1b8f..06f3b03005 100644
--- a/web/src/lib/feedback/Banner/OBanner.vue
+++ b/web/src/lib/feedback/Banner/OBanner.vue
@@ -4,7 +4,7 @@ import { computed, useSlots } from "vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
interface Props {
- variant?: "default" | "info" | "success" | "warning" | "error" | "error-soft";
+ variant?: "default" | "info" | "success" | "warning" | "error" | "error-soft" | "promo";
content?: I18nText;
icon?: string;
dense?: boolean;
@@ -17,6 +17,18 @@ interface Props {
* banners visibly ragged.
*/
bar?: boolean;
+ /**
+ * Bar mode: centre the message and its actions as one group rather than
+ * pinning actions to the right edge. For a short promotional line, where a
+ * left-aligned message with the action stranded across the viewport reads as
+ * two unrelated things.
+ */
+ center?: boolean;
+ /**
+ * Overrides the role derived from `variant`. Only for a bar that is part of
+ * the page's furniture rather than a notification.
+ */
+ role?: "status" | "alert" | "banner";
dataTest?: string;
}
@@ -25,12 +37,14 @@ const props = withDefaults(defineProps(), {
dense: false,
inlineActions: false,
bar: false,
+ center: false,
});
const slots = useSlots();
-const ariaRole = computed(() =>
- props.variant === "error" || props.variant === "warning" ? "alert" : "status",
+const ariaRole = computed(
+ () =>
+ props.role ?? (props.variant === "error" || props.variant === "warning" ? "alert" : "status"),
);
const hasDefaultSlot = computed(() => !!slots.default);
@@ -49,6 +63,9 @@ const variantClass = computed(() => {
return "bg-banner-warning-bg border border-banner-warning-border border-l-4 border-l-banner-warning-border text-banner-warning-text";
case "error":
return "bg-banner-error-bg text-banner-error-text";
+ // Marketing gold, shared with the standalone webinar bar so the two match.
+ case "promo":
+ return "bg-promo-webinar-accent text-promo-webinar-text";
// Tinted error for hints/insights — solid `error` stays for hard failures.
case "error-soft":
return "bg-banner-error-soft-bg border border-banner-error-soft-border border-l-4 border-l-banner-error-soft-border text-banner-error-soft-text";
@@ -76,6 +93,8 @@ const barVariantClass = computed(() => {
return "bg-banner-error-bg text-banner-error-text";
case "error-soft":
return "bg-banner-error-soft-bg text-banner-error-soft-text";
+ case "promo":
+ return "bg-promo-webinar-accent text-promo-webinar-text";
default:
return "bg-banner-default-bg text-banner-default-text";
}
@@ -88,7 +107,9 @@ const barVariantClass = computed(() => {
:data-test="dataTest"
:class="[
'flex',
- bar ? 'w-full flex-row flex-wrap items-center justify-between gap-3 px-4 py-2' : '',
+ bar ? 'w-full flex-row flex-wrap items-center gap-3 px-4' : '',
+ bar ? (dense ? 'py-1' : 'py-2') : '',
+ bar ? (center ? 'justify-center' : 'justify-between') : '',
bar ? '' : 'rounded-default',
bar ? '' : inlineActions ? 'flex-row items-center gap-3' : 'flex-col gap-2',
bar ? '' : dense ? 'p-2' : 'p-4',
@@ -98,7 +119,7 @@ const barVariantClass = computed(() => {
diff --git a/web/src/views/Dashboards/RenderDashboardCharts.vue b/web/src/views/Dashboards/RenderDashboardCharts.vue
index fd5548207f..c9baa03d7e 100644
--- a/web/src/views/Dashboards/RenderDashboardCharts.vue
+++ b/web/src/views/Dashboards/RenderDashboardCharts.vue
@@ -206,7 +206,7 @@ along with this program. If not, see
.
.
v-model="confirmRUMUpdate"
/>
-
- {{ t("ingestion.redirectionIngestionMsg") }}
-
+ variant="promo"
+ dense
+ class="mx-2.5 mt-1 font-bold"
+ :content="t('ingestion.redirectionIngestionMsg')"
+ />
+