fix(web): logs scroll jump, dashboard tab-rename width, and banner usage (#13858)

Three unrelated UI fixes.

### `fix: logs page wrap scroll`
The Logs view delegates its scroll element to a container that also
holds the histogram. TanStack Virtual compensates `scrollTop` whenever a
measured row differs from its estimate — correct for a self-contained
list, wrong for a shared scroller. With wrap on, rows were remeasured on
every query refresh and the deltas pushed the whole page downward.

Adds `preserveScrollOffsetOnRowResize` to `useTableVirtualization`,
which pins `shouldAdjustScrollPositionOnItemSizeChange` to `() =>
false`. `OTable` opts in only when it has both a delegated `scrollEl`
and dynamic row height, so self-contained tables keep the existing
behaviour.

### `fix: dashboard tab rename size issue`
The rename input's auto-sizer grid track was `minmax(0, max-content)`.
Once the tab strip overflowed, the `0` minimum collapsed the track to
`0px` and the input vanished. Now a bare `max-content`.

### `fix: ingestion and webinar obanner usage`
Replaces two hand-rolled banner `div`s (`Ingestion.vue`,
`WebinarBanner.vue`) with `OBanner`. To make that possible without
appearance overrides, `OBanner` gains:
- a `promo` variant (the existing marketing gold, so the standalone
webinar bar still matches);
- `center`, which groups message and actions in the middle of a bar
instead of stranding the action at the far edge;
- a `role` override, for a bar that is page furniture rather than a
notification;
- `dense` now actually applies in `bar` mode (`py-1` vs `py-2`).

### Tests
`TabList.spec.ts` and `OTable.wrapMeasure.spec.ts` updated alongside.
This commit is contained in:
ktx-vaidehi
2026-08-18 10:41:10 +00:00
committed by GitHub
parent c53a16b262
commit a6c910f015
9 changed files with 140 additions and 43 deletions
+11 -9
View File
@@ -16,16 +16,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<!-- Header variant: w-full top bar above the toolbar -->
<div
<OBanner
v-if="webinarData && !isExpired && !isDismissed && variant === 'header'"
class="webinar-top-bar bg-promo-webinar-accent text-promo-webinar-text w-full"
data-test="webinar-header-banner"
bar
dense
center
variant="promo"
role="banner"
data-test="webinar-header-banner"
>
<div
class="webinar-top-bar-content relative flex flex-wrap items-center justify-center gap-2 px-4 py-[0.2rem]"
>
<span class="webinar-top-bar-text text-compact text-promo-webinar-text text-center font-bold">
<div class="flex flex-wrap items-center justify-center gap-2">
<span class="webinar-top-bar-text text-center font-bold">
<strong>{{ webinarData.tag }}:</strong> {{ webinarData.title }}
<span v-if="webinarData.date" class="webinar-top-bar-date font-medium">
{{ formattedDate }}
@@ -37,7 +38,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
: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 <http://www.gnu.org/licenses/>.
{{ t("components.webinarBanner.dismiss") }}
</OButton>
</div>
</div>
</OBanner>
<!-- Home variant: larger banner -->
<div
@@ -128,6 +129,7 @@ import { ref, computed, onMounted, watch } from "vue";
import { useStore } from "vuex";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import { useI18nTyped, type I18nText } from "@/types/i18n";
const store = useStore();
@@ -739,6 +739,21 @@ describe("TabList", () => {
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();
@@ -46,11 +46,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Auto-size the input to its text via an invisible sizer sharing the
input's grid cell, so the field is exactly as wide as the name.
`size="1"` neutralises the input's default ~20ch intrinsic width so
the sizer alone drives the max-content column. -->
<span
v-if="editingTabId === tab.tabId"
class="grid grid-cols-[minmax(0,max-content)] items-center"
>
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. -->
<span v-if="editingTabId === tab.tabId" class="grid grid-cols-[max-content] items-center">
<span
aria-hidden="true"
class="invisible col-start-1 row-start-1 px-0.5 text-sm whitespace-pre"
+4
View File
@@ -588,6 +588,10 @@ const {
rowHeight: props.rowHeight ?? (props.dense ? 38 : 54),
overscan: props.overscan ?? 100,
dynamicRowHeight: () => 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);
@@ -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();
});
});
@@ -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<boolean>;
/**
* 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<boolean>;
}
/**
@@ -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);
+26 -5
View File
@@ -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<Props>(), {
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(() => {
<div
:class="[
'flex flex-row gap-3',
bar ? 'min-w-0 flex-1 items-center' : 'items-start',
bar ? (center ? 'min-w-0 items-center' : 'min-w-0 flex-1 items-center') : 'items-start',
inlineActions && !bar ? 'flex-1' : '',
]"
>
@@ -206,7 +206,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Panel Time Picker (NEW) -->
<div
v-if="hasPanelTime(item) && panelTimeValues[item.id]"
class="panel-time-picker-wrapper mb-2"
class="panel-time-picker-wrapper mt-1 mb-2"
:data-test="`dashboard-panel-${item.id}-time-picker`"
>
<DateTimePickerDashboard
+18 -6
View File
@@ -183,16 +183,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
v-model="confirmRUMUpdate"
/>
<!-- Empty-data warning banner -->
<div
<OBanner
v-if="
store.state.zoConfig.hasOwnProperty('restricted_routes_on_empty_data') &&
store.state.zoConfig.restricted_routes_on_empty_data == true &&
store.state.organizationData.isDataIngested == false
"
class="text-subtitle bg-warning rounded-default mx-2.5 mt-1 p-2 font-bold"
>
{{ t("ingestion.redirectionIngestionMsg") }}
</div>
variant="promo"
dense
class="mx-2.5 mt-1 font-bold"
:content="t('ingestion.redirectionIngestionMsg')"
/>
<div class="min-h-0 flex-1">
<router-view
:title="ingestTabType"
@@ -224,6 +226,7 @@ import { getImageURL } from "@/utils/zincutils";
import apiKeysService from "@/services/api_keys";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import type { SelectModelValue } from "@/lib/forms/Select/OSelect.types";
import { searchIngestionItems } from "@/utils/ingestionSearchIndex";
import { awsIntegrations } from "@/utils/awsIntegrations";
@@ -231,7 +234,16 @@ import { toast } from "@/lib/feedback/Toast/useToast";
export default defineComponent({
name: "PageIngestion",
components: { OPageLayout, ConfirmDialog, OTabs, ORouteTab, OButton, OSearchInput, OSelect },
components: {
OPageLayout,
ConfirmDialog,
OTabs,
ORouteTab,
OButton,
OSearchInput,
OSelect,
OBanner,
},
setup() {
const { t } = useI18nTyped();
const store = useStore();