mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user