diff --git a/tests/ui-testing/pages/logsPages/logsPage.js b/tests/ui-testing/pages/logsPages/logsPage.js
index 3b0869bcac..df9518bf91 100644
--- a/tests/ui-testing/pages/logsPages/logsPage.js
+++ b/tests/ui-testing/pages/logsPages/logsPage.js
@@ -122,7 +122,7 @@ export class LogsPage {
this.savedViewDialog = '[data-test="search-bar-store-state-saved-view-dialog"]';
this.savedViewArrow = '[data-test="logs-search-bar-utilities-menu-btn"]';
// OInput convention §4: drive the auto-derived `-field` inner native input for fill().
- this.savedViewSearchInput = '[data-test="log-search-saved-view-field-search-input-field"]';
+ this.savedViewSearchInput = '[data-test="logs-saved-views-dialog-search-field"]';
// Matches both ConfirmDialog.vue (data-test="confirm-dialog") and SearchBar's inline
// ODialog (data-test="search-bar-confirm-dialog") via substring selector.
this.confirmButton = '[data-test*="confirm-dialog"] [data-test="o-dialog-primary-btn"]';
@@ -133,7 +133,7 @@ export class LogsPage {
this.menuTransformEditorToggleBtn = '[data-test="logs-search-bar-menu-transform-editor-toggle-btn"]';
this.menuListSavedViewsBtn = '[data-test="logs-search-bar-menu-list-saved-views-btn"]';
this.menuCreateSavedViewBtn = '[data-test="logs-search-bar-menu-create-saved-view-btn"]';
- this.savedViewsListDialogEl = '[data-test="saved-views-list-dialog"]';
+ this.savedViewsListDialogEl = '[data-test="logs-saved-views-dialog"]';
this.menuHistogramBtn = '[data-test="logs-search-bar-menu-histogram-btn"]';
this.menuSqlModeBtn = '[data-test="logs-search-bar-menu-sql-mode-btn"]';
this.menuSqlModeBtnState = '[data-test="logs-search-bar-menu-sql-mode-btn"] [data-state]';
@@ -3349,7 +3349,7 @@ export class LogsPage {
async clickSaveViewButton() {
// Post-menu-migration: "Create saved view" moved into utilities ("More") menu.
// Close any open menus/dialogs first, then open the menu and click the item.
- const listDialog = this.page.locator('[data-test="saved-views-list-dialog"]');
+ const listDialog = this.page.locator(this.savedViewsListDialogEl);
const isListOpen = await listDialog.isVisible({ timeout: 500 }).catch(() => false);
if (isListOpen) {
await listDialog.locator('[data-test="o-dialog-close-btn"]').click();
@@ -3460,17 +3460,23 @@ export class LogsPage {
return await searchInput.fill(text);
}
+ // The `-apply-` prefix keeps the favourites pane (`-favorites-apply-`) out of the match.
+ savedViewApplyLocator(name) {
+ if (typeof name !== 'string') {
+ throw new TypeError(`savedViewApplyLocator expects the exact view name, got ${name}`);
+ }
+ return this.page.locator(`[data-test^="logs-saved-views-dialog-apply-"][data-test-view-name="${LogsPage.escapeCssAttrValue(name)}"]`);
+ }
+
async clickSavedViewByTitle(title) {
- const element = this.page.locator(`[data-test="logs-search-bar-apply-${title}-saved-view-btn"]`).first();
+ const element = this.savedViewApplyLocator(title).first();
await element.waitFor({ state: 'visible', timeout: 10000 });
// force: true — ODropdown portal transiently detaches items during initial render
return await element.click({ force: true });
}
async clickDeleteButton() {
- // Delete buttons in saved views carry data-test="logs-search-bar-delete-{view_id}-saved-view-btn"
- // Click the first visible delete button in the saved views area
- const deleteBtn = this.page.locator('[data-test*="logs-search-bar-delete-"][data-test*="-saved-view-btn"]').first();
+ const deleteBtn = this.page.locator('[data-test="logs-saved-views-dialog-table"] [data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first();
await deleteBtn.waitFor({ state: 'visible', timeout: 5000 });
return await deleteBtn.click();
}
@@ -3523,13 +3529,13 @@ export class LogsPage {
}
async clickSavedViewByText(text) {
- const element = this.page.locator(`[data-test="logs-search-bar-apply-${text}-saved-view-btn"]`).first();
+ const element = this.savedViewApplyLocator(text).first();
await element.waitFor({ state: 'visible', timeout: 10000 });
return await element.click();
}
async waitForSavedViewText(text) {
- return await this.page.locator(`[data-test="logs-search-bar-apply-${text}-saved-view-btn"]`).first().waitFor({ state: 'visible', timeout: 10000 });
+ return await this.savedViewApplyLocator(text).first().waitFor({ state: 'visible', timeout: 10000 });
}
/**
@@ -3552,7 +3558,7 @@ export class LogsPage {
async clickDeleteSavedViewButton(savedViewName) {
// The caller (clickSavedViewByTitle) closes the dialog. Wait passively for it to
// reach hidden state so the re-open sequence doesn't race the close animation.
- await this.page.locator('[data-test="saved-views-list-dialog"]')
+ await this.page.locator(this.savedViewsListDialogEl)
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {});
@@ -3574,8 +3580,8 @@ export class LogsPage {
// The delete button data-test uses view_id (a UUID), not the view name, so we
// can't build the exact selector. Instead scope to the main saved-views table
// (not favorites) — after filtering by name there is exactly one visible row.
- const mainTable = this.page.locator('[data-test="log-search-saved-view-list-fields-table"]');
- const deleteBtn = mainTable.locator('[data-test*="logs-search-bar-delete-"]').first();
+ const mainTable = this.page.locator('[data-test="logs-saved-views-dialog-table"]');
+ const deleteBtn = mainTable.locator('[data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first();
await deleteBtn.waitFor({ state: 'visible', timeout: 10000 });
await deleteBtn.scrollIntoViewIfNeeded();
await deleteBtn.click();
@@ -4082,7 +4088,7 @@ export class LogsPage {
}
async clickSavedViewByLabel(label) {
- const element = this.page.locator(`[data-test="logs-search-bar-apply-${label}-saved-view-btn"]`).first();
+ const element = this.savedViewApplyLocator(label).first();
await element.waitFor({ state: 'visible', timeout: 10000 });
return await element.click({ force: true });
}
@@ -8878,9 +8884,7 @@ export class LogsPage {
* @param {string} name - Saved view name
*/
async clickSavedViewByName(name) {
- // SearchBar.vue uses data-test="logs-search-bar-apply-${value}-saved-view-btn" for the apply button.
- // The previously used `logs-search-saved-view-item-${name}` does not exist in the DOM.
- const savedView = this.page.locator(`[data-test="logs-search-bar-apply-${name}-saved-view-btn"]`).first();
+ const savedView = this.savedViewApplyLocator(name).first();
await savedView.waitFor({ state: 'visible', timeout: 10000 });
// force: true — ODropdown portal transiently detaches items during initial render
await savedView.click({ force: true });
@@ -8954,8 +8958,8 @@ export class LogsPage {
*/
async clickDeleteSavedViewByName(name) {
// data-test uses view_id (UUID), not name — scope to main table after filtering.
- const mainTable = this.page.locator('[data-test="log-search-saved-view-list-fields-table"]');
- const deleteBtn = mainTable.locator('[data-test*="logs-search-bar-delete-"]').first();
+ const mainTable = this.page.locator('[data-test="logs-saved-views-dialog-table"]');
+ const deleteBtn = mainTable.locator('[data-test^="logs-saved-views-dialog-delete-"]:not([data-test$="-menu"])').first();
await deleteBtn.waitFor({ state: 'visible', timeout: 10000 });
await deleteBtn.click();
testLogger.info(`Clicked delete for saved view: ${name}`);
diff --git a/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js b/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js
index fb99dc3dc7..6bee1b10a5 100644
--- a/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js
+++ b/tests/ui-testing/playwright-tests/Logs/logsqueries.spec.js
@@ -77,13 +77,14 @@ test.describe("Logs Queries testcases", () => {
await pm.logsPage.fillSavedViewName("e2etimestamp");
await pm.logsPage.clickSavedViewDialogSaveContent();
await pm.logsPage.clickSavedViewArrow();
- await pm.logsPage.clickSavedViewByLabel(/timestamp/);
+ await pm.logsPage.clickSavedViewByLabel('e2etimestamp');
await pm.logsPage.clickSavedViewsExpand();
await pm.logsPage.clickSavedViewSearchInput();
await pm.logsPage.fillSavedViewSearchInput('e2e');
await pm.logsPage.clickSavedViewByTitle('e2etimestamp');
await pm.logsPage.clickSavedViewsExpand();
await pm.logsPage.clickSavedViewSearchInput();
+ await pm.logsPage.fillSavedViewSearchInput('e2e');
await pm.logsPage.clickSavedViewByTitle('e2etimestamp');
await pm.logsPage.clickDeleteButton();
await pm.logsPage.clickConfirmButton();
diff --git a/web/src/components/savedViews/SavedViewsListDialog.spec.ts b/web/src/components/savedViews/SavedViewsListDialog.spec.ts
new file mode 100644
index 0000000000..6c3d4fe8ab
--- /dev/null
+++ b/web/src/components/savedViews/SavedViewsListDialog.spec.ts
@@ -0,0 +1,307 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect, afterEach, vi } from "vitest";
+import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
+import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (k: string) => k, te: () => true }),
+}));
+
+const views = [
+ { view_id: "t1", view_name: "checkout errors" },
+ { view_id: "t2", view_name: "slow payments" },
+];
+
+// The real ODialog portals out of the wrapper; render its body inline instead.
+const ODialogStub = {
+ name: "ODialog",
+ props: ["open", "size", "title"],
+ emits: ["update:open"],
+ template: '
',
+};
+
+// The real dropdown portals its items; render them inline so the < md menu is reachable.
+const ODropdownStub = {
+ name: "ODropdown",
+ template: '
',
+};
+
+const ODropdownItemStub = {
+ name: "ODropdownItem",
+ props: ["iconLeft", "variant"],
+ emits: ["select"],
+ template: '
',
+};
+
+describe("SavedViewsListDialog", () => {
+ let wrapper: VueWrapper | undefined;
+
+ const mountDialog = (props: Record = {}) =>
+ mount(SavedViewsListDialog, {
+ props: {
+ open: true,
+ views,
+ favoriteIds: [],
+ favoriteViews: [],
+ dataTestPrefix: "traces-saved-views-dialog",
+ ...props,
+ },
+ global: {
+ stubs: {
+ ODialog: ODialogStub,
+ ODropdown: ODropdownStub,
+ ODropdownItem: ODropdownItemStub,
+ },
+ },
+ });
+
+ const find = (dataTest: string) => wrapper!.find(`[data-test="${dataTest}"]`);
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = undefined;
+ });
+
+ it("lists only the views passed in", async () => {
+ wrapper = mountDialog({ views: [views[0]] });
+ await flushPromises();
+
+ expect(find("traces-saved-views-dialog-apply-t1").text()).toBe("checkout errors");
+ expect(find("traces-saved-views-dialog-apply-t2").exists()).toBe(false);
+ });
+
+ it("filters the list by name, case-insensitively", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ await wrapper.findComponent({ name: "OSearchInput" }).vm.$emit("update:modelValue", "SLOW");
+ await flushPromises();
+
+ expect(find("traces-saved-views-dialog-apply-t1").exists()).toBe(false);
+ expect(find("traces-saved-views-dialog-apply-t2").exists()).toBe(true);
+ expect(wrapper.emitted("update:search")?.[0]).toEqual(["SLOW"]);
+ });
+
+ it("clears the search each time the dialog opens", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+ await wrapper.findComponent({ name: "OSearchInput" }).vm.$emit("update:modelValue", "SLOW");
+ await flushPromises();
+ expect(find("traces-saved-views-dialog-apply-t1").exists()).toBe(false);
+
+ await wrapper.setProps({ open: false });
+ await wrapper.setProps({ open: true });
+ await flushPromises();
+
+ expect(wrapper.findComponent({ name: "OSearchInput" }).props("modelValue")).toBe("");
+ expect(find("traces-saved-views-dialog-apply-t1").exists()).toBe(true);
+ expect(wrapper.emitted("update:search")?.at(-1)).toEqual([""]);
+ });
+
+ it("clears a bound search model when the dialog opens", async () => {
+ wrapper = mountDialog({ open: false, search: "SLOW" });
+ await wrapper.setProps({ open: true });
+ await flushPromises();
+
+ expect(wrapper.emitted("update:search")).toEqual([[""]]);
+ });
+
+ it("emits apply and closes when a view is chosen", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ await find("traces-saved-views-dialog-apply-t2").trigger("click");
+
+ expect(wrapper.emitted("apply")?.[0]).toEqual([views[1]]);
+ expect(wrapper.emitted("update:open")?.[0]).toEqual([false]);
+ });
+
+ it("emits update and delete without closing itself", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ await find("traces-saved-views-dialog-update-t1").trigger("click");
+ await find("traces-saved-views-dialog-delete-t2").trigger("click");
+
+ expect(wrapper.emitted("update")?.[0]).toEqual([views[0]]);
+ expect(wrapper.emitted("delete")?.[0]).toEqual([views[1]]);
+ expect(wrapper.emitted("apply")).toBeUndefined();
+ expect(wrapper.emitted("update:open")).toBeUndefined();
+ });
+
+ it("emits edit and delete from the < md row menu", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ expect(find("traces-saved-views-dialog-more-actions-t1").classes()).toContain("md:hidden");
+ await find("traces-saved-views-dialog-update-t1-menu").trigger("click");
+ await find("traces-saved-views-dialog-delete-t1-menu").trigger("click");
+
+ expect(wrapper.emitted("update")?.[0]).toEqual([views[0]]);
+ expect(wrapper.emitted("delete")?.[0]).toEqual([views[0]]);
+ });
+
+ it("hides edit and delete buttons below md and reveals row actions on hover", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ const edit = find("traces-saved-views-dialog-update-t1");
+ expect(edit.classes()).toEqual(
+ expect.arrayContaining(["max-md:hidden", "opacity-0", "group-hover/row:opacity-100"]),
+ );
+ expect(find("traces-saved-views-dialog-favorite-t1").classes()).not.toContain("max-md:hidden");
+ });
+
+ it("shows the empty state only when not loading", async () => {
+ wrapper = mountDialog({ views: [] });
+ await flushPromises();
+ expect(find("traces-saved-views-dialog-empty").text()).toContain("search.savedViewsNotFound");
+ expect(find("traces-saved-views-dialog-loading").exists()).toBe(false);
+
+ await wrapper.setProps({ loading: true });
+ await flushPromises();
+ expect(find("traces-saved-views-dialog-empty").exists()).toBe(false);
+ expect(find("traces-saved-views-dialog-loading").text()).toContain("confirmDialog.loading");
+ });
+
+ it("renders the favourites pane only when there are favourites", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+ expect(find("traces-saved-views-dialog-favorites-table").exists()).toBe(false);
+ expect(find("traces-saved-views-dialog-table").element.parentElement!.className).toContain(
+ "w-full",
+ );
+
+ await wrapper.setProps({ favoriteIds: ["t2"], favoriteViews: [views[1]] });
+ await flushPromises();
+
+ const favorites = find("traces-saved-views-dialog-favorites-table");
+ expect(favorites.exists()).toBe(true);
+ expect(favorites.text()).toContain("search.favoriteViews");
+ const header = favorites.element.firstElementChild as HTMLElement;
+ expect(header.textContent!.trim()).toBe("search.favoriteViews");
+ const below = header.nextElementSibling as HTMLElement;
+ for (const el of [header, below]) {
+ expect(el.tagName).not.toBe("HR");
+ expect(el.className).not.toMatch(/(^|\s)border(-[tb])?(\s|$)/);
+ }
+ expect(favorites.find("hr").exists()).toBe(false);
+ expect(
+ favorites.find('[data-test="traces-saved-views-dialog-favorites-apply-t2"]').text(),
+ ).toBe("slow payments");
+ expect(find("traces-saved-views-dialog-table").element.parentElement!.className).toContain(
+ "w-3/5",
+ );
+ });
+
+ it("toggles favourites with the current state, and the favourites pane always removes", async () => {
+ wrapper = mountDialog({ favoriteIds: ["t2"], favoriteViews: [views[1]] });
+ await flushPromises();
+
+ const starIcon = (dataTest: string) =>
+ find(dataTest).findComponent({ name: "OIcon" }).props("name");
+ expect(starIcon("traces-saved-views-dialog-favorite-t1")).toBe("star-outline");
+ expect(starIcon("traces-saved-views-dialog-favorite-t2")).toBe("star");
+
+ await find("traces-saved-views-dialog-favorite-t1").trigger("click");
+ await find("traces-saved-views-dialog-favorite-t2").trigger("click");
+ await find("traces-saved-views-dialog-favorites-favorite-t2").trigger("click");
+
+ expect(wrapper.emitted("toggle-favorite")).toEqual([
+ [views[0], false],
+ [views[1], true],
+ [views[1], true],
+ ]);
+ });
+
+ it("applies from the favourites pane and closes", async () => {
+ wrapper = mountDialog({ favoriteIds: ["t2"], favoriteViews: [views[1]] });
+ await flushPromises();
+
+ await find("traces-saved-views-dialog-favorites-apply-t2").trigger("click");
+
+ expect(wrapper.emitted("apply")?.[0]).toEqual([views[1]]);
+ expect(wrapper.emitted("update:open")?.[0]).toEqual([false]);
+ });
+
+ it("renders compact rows with no header and no pager border", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ const table = wrapper.findComponent({ name: "OTable" });
+ expect(table.props("compact")).toBe(true);
+ expect(table.props("showHeader")).toBe(false);
+ expect(table.props("paginationBordered")).toBe(false);
+ expect(table.find("thead").exists()).toBe(false);
+ });
+
+ it("renders the actions as an unpinned, right-aligned action cell", async () => {
+ wrapper = mountDialog();
+ await flushPromises();
+
+ const cell = wrapper.find('[data-test="o2-table-cell-actions"]');
+ expect(cell.attributes("style") ?? "").not.toContain("sticky");
+ expect(cell.classes()).toContain("text-right");
+ expect(cell.element.firstElementChild!.className).toContain("inline-flex");
+ expect(cell.find(".truncate").exists()).toBe(false);
+ });
+
+ it("derives every data-test from dataTestPrefix", async () => {
+ const p = "logs-saved-views-dialog";
+ wrapper = mountDialog({ dataTestPrefix: p, favoriteIds: ["t2"], favoriteViews: [views[1]] });
+ await flushPromises();
+
+ const expected = [
+ p,
+ `${p}-list`,
+ `${p}-search`,
+ `${p}-search-field`,
+ `${p}-table`,
+ `${p}-favorites-table`,
+ `${p}-apply-t1`,
+ `${p}-favorite-t1`,
+ `${p}-update-t1`,
+ `${p}-delete-t1`,
+ `${p}-more-actions-t1`,
+ `${p}-update-t1-menu`,
+ `${p}-delete-t1-menu`,
+ `${p}-favorites-apply-t2`,
+ `${p}-favorites-favorite-t2`,
+ `${p}-favorites-update-t2`,
+ `${p}-favorites-delete-t2`,
+ `${p}-favorites-more-actions-t2`,
+ `${p}-favorites-update-t2-menu`,
+ `${p}-favorites-delete-t2-menu`,
+ ];
+ for (const id of expected) {
+ expect(wrapper.find(`[data-test="${id}"]`).exists(), id).toBe(true);
+ }
+ expect(find(`${p}-apply-t1`).attributes("data-test-view-name")).toBe("checkout errors");
+ expect(find(`${p}-favorites-apply-t2`).attributes("data-test-view-name")).toBe("slow payments");
+ expect(wrapper.find('[data-test^="traces-"]').exists()).toBe(false);
+ });
+
+ it("re-renders data-test values when dataTestPrefix changes", async () => {
+ wrapper = mountDialog();
+ await wrapper.setProps({ dataTestPrefix: "other-dialog" });
+
+ expect(find("other-dialog").exists()).toBe(true);
+ expect(find("other-dialog-table").exists()).toBe(true);
+ expect(find("other-dialog-apply-t1").exists()).toBe(true);
+ expect(find("traces-saved-views-dialog-table").exists()).toBe(false);
+ });
+});
diff --git a/web/src/components/savedViews/SavedViewsListDialog.vue b/web/src/components/savedViews/SavedViewsListDialog.vue
new file mode 100644
index 0000000000..aa039988cd
--- /dev/null
+++ b/web/src/components/savedViews/SavedViewsListDialog.vue
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("confirmDialog.loading") }}
+
+
+
+
+ {{ t("search.favoriteViews") }}
+
+
+
+
+
+ {{ value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("common.edit") }}
+
+
+ {{ t("common.delete") }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/plugins/logs/savedViewsSort.spec.ts b/web/src/components/savedViews/savedViewsSort.spec.ts
similarity index 100%
rename from web/src/plugins/logs/savedViewsSort.spec.ts
rename to web/src/components/savedViews/savedViewsSort.spec.ts
diff --git a/web/src/plugins/logs/savedViewsSort.ts b/web/src/components/savedViews/savedViewsSort.ts
similarity index 100%
rename from web/src/plugins/logs/savedViewsSort.ts
rename to web/src/components/savedViews/savedViewsSort.ts
diff --git a/web/src/composables/useFavoriteSavedViews.spec.ts b/web/src/composables/useFavoriteSavedViews.spec.ts
new file mode 100644
index 0000000000..b587e99333
--- /dev/null
+++ b/web/src/composables/useFavoriteSavedViews.spec.ts
@@ -0,0 +1,221 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { reactive } from "vue";
+
+const { toastMock, storeState } = vi.hoisted(() => ({
+ toastMock: vi.fn(),
+ storeState: { selectedOrganization: { identifier: "org-a" } },
+}));
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (k: string) => k }),
+}));
+
+vi.mock("vuex", () => ({
+ useStore: () => ({ state: storeState }),
+}));
+
+vi.mock("@/lib/feedback/Toast/useToast", () => ({
+ toast: toastMock,
+}));
+
+import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews";
+
+const view = (id: string, org = "org-a", type?: string) => ({
+ view_id: id,
+ view_name: `view ${id}`,
+ org_id: org,
+ ...(type ? { view_type: type } : {}),
+});
+
+const stored = () => JSON.parse(localStorage.getItem("savedViews") || "{}");
+
+describe("useFavoriteSavedViews", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ toastMock.mockClear();
+ storeState.selectedOrganization = reactive({ identifier: "org-a" });
+ });
+
+ it("reads the existing { [view_id]: row } storage shape", () => {
+ localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2") }));
+
+ const { favoriteIds, favoriteViews } = useFavoriteSavedViews("logs");
+
+ expect(favoriteIds.value).toEqual(["l1", "l2"]);
+ expect(favoriteViews.value).toEqual([view("l1"), view("l2")]);
+ });
+
+ it("filters by the current org and by view type, rows without a type being logs", () => {
+ localStorage.setItem(
+ "savedViews",
+ JSON.stringify({
+ l1: view("l1"),
+ l2: view("l2", "org-b"),
+ t1: view("t1", "org-a", "traces"),
+ l3: view("l3", "org-a", "logs"),
+ }),
+ );
+
+ expect(useFavoriteSavedViews("logs").favoriteIds.value).toEqual(["l1", "l3"]);
+ expect(useFavoriteSavedViews("traces").favoriteIds.value).toEqual(["t1"]);
+ });
+
+ it("ignores unreadable storage", () => {
+ localStorage.setItem("savedViews", "not json");
+ expect(useFavoriteSavedViews("logs").favoriteIds.value).toEqual([]);
+ });
+
+ it("adds a favourite, keeps other entries and toasts", () => {
+ localStorage.setItem("savedViews", JSON.stringify({ x1: view("x1", "org-b") }));
+ const { favoriteIds, toggleFavorite } = useFavoriteSavedViews("traces");
+
+ toggleFavorite(view("t1", "org-a", "traces"), false);
+
+ expect(favoriteIds.value).toEqual(["t1"]);
+ expect(Object.keys(stored())).toEqual(["x1", "t1"]);
+ expect(toastMock).toHaveBeenCalledWith({
+ message: "logs.searchBar.viewAddedFavorites",
+ variant: "success",
+ });
+ });
+
+ it("removes a favourite when it is already one", () => {
+ localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2") }));
+ const { favoriteIds, toggleFavorite } = useFavoriteSavedViews("logs");
+
+ toggleFavorite(view("l1"), true);
+
+ expect(favoriteIds.value).toEqual(["l2"]);
+ expect(Object.keys(stored())).toEqual(["l2"]);
+ expect(toastMock).toHaveBeenCalledWith({
+ message: "logs.searchBar.viewRemovedFavorites",
+ variant: "success",
+ });
+ });
+
+ it("caps favourites at 10 per org and view type", () => {
+ const entries: Record = {};
+ for (let i = 0; i < 10; i++) entries[`l${i}`] = view(`l${i}`);
+ entries.b1 = view("b1", "org-b");
+ localStorage.setItem("savedViews", JSON.stringify(entries));
+
+ const logs = useFavoriteSavedViews("logs");
+ logs.toggleFavorite(view("l10"), false);
+ expect(logs.favoriteIds.value).toHaveLength(10);
+ expect(stored().l10).toBeUndefined();
+ expect(toastMock).toHaveBeenCalledWith({
+ message: "logs.searchBar.maxViewsLimit",
+ variant: "warning",
+ });
+
+ const traces = useFavoriteSavedViews("traces");
+ traces.toggleFavorite(view("t1", "org-a", "traces"), false);
+ expect(traces.favoriteIds.value).toEqual(["t1"]);
+ });
+
+ it("counts favourites written by another instance before applying the cap", () => {
+ const first = useFavoriteSavedViews("logs");
+ const second = useFavoriteSavedViews("logs");
+ for (let i = 0; i < 10; i++) first.toggleFavorite(view(`l${i}`), false);
+
+ second.toggleFavorite(view("l10"), false);
+
+ expect(stored().l10).toBeUndefined();
+ });
+
+ it("removeFavorite drops the entry silently", () => {
+ localStorage.setItem("savedViews", JSON.stringify({ t1: view("t1", "org-a", "traces") }));
+ const { favoriteIds, removeFavorite } = useFavoriteSavedViews("traces");
+
+ removeFavorite("t1");
+
+ expect(favoriteIds.value).toEqual([]);
+ expect(stored()).toEqual({});
+ expect(toastMock).not.toHaveBeenCalled();
+ });
+
+ it("follows an org switch", () => {
+ localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2", "org-b") }));
+ const { favoriteIds } = useFavoriteSavedViews("logs");
+ expect(favoriteIds.value).toEqual(["l1"]);
+
+ storeState.selectedOrganization.identifier = "org-b";
+
+ expect(favoriteIds.value).toEqual(["l2"]);
+ });
+
+ it("prunes only this org's and view type's favourites missing from the live list", () => {
+ localStorage.setItem(
+ "savedViews",
+ JSON.stringify({
+ t1: view("t1", "org-a", "traces"),
+ t2: view("t2", "org-a", "traces"),
+ tb: view("tb", "org-b", "traces"),
+ l1: view("l1"),
+ }),
+ );
+ const { favoriteIds, pruneFavorites } = useFavoriteSavedViews("traces");
+
+ pruneFavorites(["t1"]);
+
+ expect(favoriteIds.value).toEqual(["t1"]);
+ expect(Object.keys(stored())).toEqual(["t1", "tb", "l1"]);
+ });
+
+ it("re-reads storage before pruning and skips the write when nothing is stale", () => {
+ const { favoriteIds, pruneFavorites } = useFavoriteSavedViews("logs");
+ localStorage.setItem("savedViews", JSON.stringify({ l1: view("l1"), l2: view("l2") }));
+ const setItem = vi.spyOn(Storage.prototype, "setItem");
+
+ pruneFavorites(["l1", "l2"]);
+ expect(setItem).not.toHaveBeenCalled();
+
+ pruneFavorites(["l2"]);
+ expect(setItem).toHaveBeenCalledTimes(1);
+ expect(favoriteIds.value).toEqual(["l2"]);
+ setItem.mockRestore();
+ });
+
+ it("frees the cap once deleted favourites are pruned", () => {
+ const entries: Record = {};
+ for (let i = 0; i < 10; i++) entries[`l${i}`] = view(`l${i}`);
+ localStorage.setItem("savedViews", JSON.stringify(entries));
+ const logs = useFavoriteSavedViews("logs");
+
+ logs.pruneFavorites(["l0", "l1", "l2", "l3", "l4", "l5", "l6", "l7", "l8"]);
+ logs.toggleFavorite(view("l10"), false);
+
+ expect(stored().l9).toBeUndefined();
+ expect(stored().l10).toEqual(view("l10"));
+ expect(logs.favoriteIds.value).toHaveLength(10);
+ });
+
+ it("keeps working in memory when localStorage rejects the write", () => {
+ const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new DOMException("quota", "QuotaExceededError");
+ });
+ const { favoriteIds, toggleFavorite, removeFavorite, pruneFavorites } =
+ useFavoriteSavedViews("logs");
+
+ expect(() => toggleFavorite(view("l1"), false)).not.toThrow();
+ expect(favoriteIds.value).toEqual(["l1"]);
+ expect(() => removeFavorite("l1")).not.toThrow();
+ expect(() => pruneFavorites([])).not.toThrow();
+ setItem.mockRestore();
+ });
+});
diff --git a/web/src/composables/useFavoriteSavedViews.ts b/web/src/composables/useFavoriteSavedViews.ts
new file mode 100644
index 0000000000..c8dda54376
--- /dev/null
+++ b/web/src/composables/useFavoriteSavedViews.ts
@@ -0,0 +1,104 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { computed, ref } from "vue";
+import { useStore } from "vuex";
+import { useI18nTyped } from "@/types/i18n";
+import { toast } from "@/lib/feedback/Toast/useToast";
+import { viewTypeOf } from "@/services/saved_views";
+
+// One key for every org and view type, so favourites saved before view types existed still load.
+const STORAGE_KEY = "savedViews";
+const MAX_FAVORITES = 10;
+
+export type FavoriteViewType = "logs" | "traces";
+
+export interface FavoriteSavedView {
+ view_id: string;
+ view_name: string;
+ org_id?: string;
+ view_type?: string | null;
+ [key: string]: unknown;
+}
+
+const readStore = (): Record => {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
+ } catch {
+ return {};
+ }
+};
+
+const writeStore = (value: Record) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
+ } catch {
+ // A full or blocked localStorage must not fail the caller; the in-memory favourites still update.
+ }
+};
+
+/** Favourite saved views of one view type in the current org, persisted in localStorage. */
+export function useFavoriteSavedViews(viewType: FavoriteViewType) {
+ const store = useStore();
+ const { t } = useI18nTyped();
+ const stored = ref>(readStore());
+
+ const isOwnFavorite = (view: FavoriteSavedView) =>
+ view?.org_id === store.state.selectedOrganization?.identifier && viewTypeOf(view) === viewType;
+
+ const favoriteViews = computed(() => Object.values(stored.value).filter(isOwnFavorite));
+ const favoriteIds = computed(() => favoriteViews.value.map((view) => view.view_id));
+
+ const removeFavorite = (viewId: string) => {
+ const next = readStore();
+ delete next[viewId];
+ writeStore(next);
+ stored.value = next;
+ };
+
+ const toggleFavorite = (row: FavoriteSavedView, isFavorite: boolean) => {
+ if (isFavorite) {
+ removeFavorite(row.view_id);
+ toast({ message: t("logs.searchBar.viewRemovedFavorites"), variant: "success" });
+ return;
+ }
+ const next = readStore();
+ stored.value = next;
+ if (favoriteIds.value.length >= MAX_FAVORITES) {
+ toast({ message: t("logs.searchBar.maxViewsLimit"), variant: "warning" });
+ return;
+ }
+ next[row.view_id] = JSON.parse(JSON.stringify(row));
+ writeStore(next);
+ stored.value = { ...next };
+ toast({ message: t("logs.searchBar.viewAddedFavorites"), variant: "success" });
+ };
+
+ /** Drops this org's and view type's favourites missing from a complete, successfully fetched list. */
+ const pruneFavorites = (liveIds: string[]) => {
+ const live = new Set(liveIds);
+ const next = readStore();
+ const stale = Object.keys(next).filter(
+ (key) => isOwnFavorite(next[key]) && !live.has(next[key].view_id),
+ );
+ if (!stale.length) return;
+ stale.forEach((key) => delete next[key]);
+ writeStore(next);
+ stored.value = next;
+ };
+
+ return { favoriteIds, favoriteViews, toggleFavorite, removeFavorite, pruneFavorites };
+}
diff --git a/web/src/composables/useLogs/useSearchBar.spec.ts b/web/src/composables/useLogs/useSearchBar.spec.ts
index 8af947c090..999946ce61 100644
--- a/web/src/composables/useLogs/useSearchBar.spec.ts
+++ b/web/src/composables/useLogs/useSearchBar.spec.ts
@@ -339,6 +339,19 @@ describe("useSearchBar Composable", () => {
const ids = searchState().searchObj.data.savedViews.map((v: any) => v.view_id);
expect(ids).toEqual(["1", "2"]);
});
+
+ it("resolves with the complete list on success and undefined on failure", async () => {
+ const views = [
+ { view_id: "1", view_name: "legacy" },
+ { view_id: "3", view_name: "traces", view_type: "traces" },
+ ];
+ mockSavedViewsGet.mockResolvedValue({ data: { views } });
+ await expect(wrapper.vm.getSavedViews(true)).resolves.toEqual(views);
+
+ mockSavedViewsGet.mockRejectedValue(new Error("boom"));
+ await expect(wrapper.vm.getSavedViews(true)).resolves.toBeUndefined();
+ expect(searchState().searchObj.loadingSavedView).toBe(false);
+ });
});
describe("getFunctions", () => {
diff --git a/web/src/composables/useLogs/useSearchBar.ts b/web/src/composables/useLogs/useSearchBar.ts
index 8fd6d7c157..69c8b7087f 100644
--- a/web/src/composables/useLogs/useSearchBar.ts
+++ b/web/src/composables/useLogs/useSearchBar.ts
@@ -86,31 +86,36 @@ export const useSearchBar = (t: TranslateFn) => {
// `force` for the reloads that follow a create/update/delete; a plain call on
// Logs entry is a cache hit.
- const getSavedViews = async (force = false) => {
+ /** Resolves with the org's complete saved-views list, or undefined when the fetch failed. */
+ const getSavedViews = async (force = false): Promise => {
try {
searchObj.loadingSavedView = true;
const org = store.state.selectedOrganization.identifier;
- (force
- ? queryClient
- .invalidateQueries({
- queryKey: savedViewsQuery(org).queryKey,
- exact: true,
- refetchType: "none",
- })
- .then(() => queryClient.fetchQuery(savedViewsQuery(org)))
- : queryClient.fetchQuery(savedViewsQuery(org))
+ return await (
+ force
+ ? queryClient
+ .invalidateQueries({
+ queryKey: savedViewsQuery(org).queryKey,
+ exact: true,
+ refetchType: "none",
+ })
+ .then(() => queryClient.fetchQuery(savedViewsQuery(org)))
+ : queryClient.fetchQuery(savedViewsQuery(org))
)
.then((views: any[]) => {
searchObj.loadingSavedView = false;
searchObj.data.savedViews = views.filter((v) => viewTypeOf(v) === "logs");
+ return views;
})
.catch((err) => {
searchObj.loadingSavedView = false;
console.log(err);
+ return undefined;
});
} catch (e: any) {
searchObj.loadingSavedView = false;
console.log("Error while getting saved views", e);
+ return undefined;
}
};
diff --git a/web/src/composables/useToolbarPins.spec.ts b/web/src/composables/useToolbarPins.spec.ts
index 8d035bbc67..457eab5484 100644
--- a/web/src/composables/useToolbarPins.spec.ts
+++ b/web/src/composables/useToolbarPins.spec.ts
@@ -60,3 +60,104 @@ describe("useToolbarPins defaults", () => {
expect(pinnedItems.value).toEqual(["histogram", "sqlMode", "syntaxGuide"]);
});
});
+
+describe("useToolbarPins scopes", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("keeps the logs storage key and defaults unchanged", async () => {
+ const { useToolbarPins } = await importFresh();
+ const { togglePin, pinnedItems } = useToolbarPins();
+ expect(pinnedItems.value).toEqual(["histogram"]);
+ togglePin("sqlMode");
+ expect(JSON.parse(window.localStorage.getItem("logs_toolbar_pinned_items")!)).toEqual([
+ "histogram",
+ "sqlMode",
+ ]);
+ expect(window.localStorage.getItem("traces_toolbar_pinned_items")).toBeNull();
+ });
+
+ it("pins traces saved views by default", async () => {
+ const { useToolbarPins } = await importFresh();
+ const { isPinned, pinnedItems } = useToolbarPins("traces");
+ expect(isPinned("savedViews")).toBe(true);
+ expect(isPinned("histogram")).toBe(false);
+ expect(pinnedItems.value).toEqual(["savedViews"]);
+ });
+
+ it("pins traces RED Metrics through the histogram key in canonical order", async () => {
+ const { useToolbarPins } = await importFresh();
+ const { togglePin, pinnedItems } = useToolbarPins("traces");
+ togglePin("histogram");
+ expect(pinnedItems.value).toEqual(["histogram", "savedViews"]);
+ expect(JSON.parse(window.localStorage.getItem("traces_toolbar_pinned_items")!)).toEqual([
+ "savedViews",
+ "histogram",
+ ]);
+ const fresh = await importFresh();
+ expect(fresh.useToolbarPins("traces").isPinned("histogram")).toBe(true);
+ });
+
+ it("keeps the traces and logs histogram pins independent", async () => {
+ const { useToolbarPins } = await importFresh();
+ useToolbarPins("traces").togglePin("histogram");
+ expect(useToolbarPins("traces").isPinned("histogram")).toBe(true);
+ useToolbarPins().togglePin("histogram");
+ expect(useToolbarPins().isPinned("histogram")).toBe(false);
+ expect(useToolbarPins("traces").isPinned("histogram")).toBe(true);
+ useToolbarPins("traces").togglePin("histogram");
+ expect(useToolbarPins("traces").isPinned("histogram")).toBe(false);
+ expect(useToolbarPins().isPinned("histogram")).toBe(false);
+ });
+
+ it("isolates scopes from each other", async () => {
+ const { useToolbarPins } = await importFresh();
+ useToolbarPins("traces").togglePin("savedViews");
+ expect(useToolbarPins("traces").isPinned("savedViews")).toBe(false);
+ expect(useToolbarPins().isPinned("savedViews")).toBe(false);
+ useToolbarPins().togglePin("savedViews");
+ expect(useToolbarPins().isPinned("savedViews")).toBe(true);
+ expect(useToolbarPins("traces").isPinned("savedViews")).toBe(false);
+ expect(window.localStorage.getItem("logs_toolbar_histogram_pin_decided")).toBeNull();
+ });
+
+ it("keeps instances of one scope in sync", async () => {
+ const { useToolbarPins } = await importFresh();
+ const a = useToolbarPins("traces");
+ const b = useToolbarPins("traces");
+ a.togglePin("savedViews");
+ expect(b.isPinned("savedViews")).toBe(false);
+ });
+
+ it("keeps traces saved views unpinned across a module reload", async () => {
+ const { useToolbarPins } = await importFresh();
+ useToolbarPins("traces").togglePin("savedViews");
+ expect(window.localStorage.getItem("traces_toolbar_saved_views_pin_decided")).toBe("true");
+ const fresh = await importFresh();
+ expect(fresh.useToolbarPins("traces").isPinned("savedViews")).toBe(false);
+ });
+
+ it("keeps each default's decided flag when stored pins are corrupt", async () => {
+ window.localStorage.setItem("logs_toolbar_pinned_items", "{not json");
+ window.localStorage.setItem("traces_toolbar_pinned_items", "{not json");
+ window.localStorage.setItem("traces_toolbar_saved_views_pin_decided", "true");
+ const { useToolbarPins } = await importFresh();
+ expect(useToolbarPins("traces").isPinned("savedViews")).toBe(false);
+ expect(useToolbarPins().isPinned("histogram")).toBe(true);
+ });
+});
+
+describe("useToolbarPins scope keys", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ });
+
+ it("ignores a key outside the scope's key list", async () => {
+ const { useToolbarPins } = await importFresh();
+ const traces = useToolbarPins("traces");
+ traces.togglePin("sqlMode");
+ expect(traces.isPinned("sqlMode")).toBe(false);
+ expect(window.localStorage.getItem("traces_toolbar_pinned_items")).toBeNull();
+ });
+});
diff --git a/web/src/composables/useToolbarPins.ts b/web/src/composables/useToolbarPins.ts
index ffe13aadb9..b8a99b1dee 100644
--- a/web/src/composables/useToolbarPins.ts
+++ b/web/src/composables/useToolbarPins.ts
@@ -1,23 +1,27 @@
// Copyright 2026 OpenObserve Inc.
//
-// Toolbar "pin" preferences composable.
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
//
-// Lets the user pin items out of the logs "More" (utilities) menu so they render
-// as fixed-position toolbar controls. The set of pinned item keys is persisted to
-// localStorage and shared reactively across every consumer via a module-level ref.
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
//
-// Usage:
-// const { isPinned, togglePin, pinnedItems } = useToolbarPins();
-// isPinned("sqlMode") // -> boolean
-// togglePin("sqlMode") // pin / unpin and persist
-// pinnedItems.value // -> ordered list of currently pinned keys
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
-import { computed, ref } from "vue";
+// Pin state is module-level per scope, so every useToolbarPins(scope) caller shares one reactive set.
+
+import { computed, ref, type Ref } from "vue";
-// Canonical keys for every pinnable item in the logs More menu.
export type ToolbarPinKey =
"histogram" | "sqlMode" | "quickMode" | "functionEditor" | "savedViews" | "syntaxGuide";
+export type ToolbarPinScope = "logs" | "traces";
+
// Fixed left-to-right render order for pinned controls. Pinning never changes an
// item's position — it only toggles whether the item is shown outside the menu.
export const TOOLBAR_PIN_ORDER: ToolbarPinKey[] = [
@@ -29,58 +33,90 @@ export const TOOLBAR_PIN_ORDER: ToolbarPinKey[] = [
"syntaxGuide",
];
-const STORAGE_KEY = "logs_toolbar_pinned_items";
+interface ToolbarPinScopeConfig {
+ storageKey: string;
+ keys: ToolbarPinKey[];
+ // A default pin's absence from storageKey means "never decided" until its decided flag is set.
+ defaultPins: Partial>;
+}
-// Items pinned by default. An item's absence from STORAGE_KEY (including
-// arrays persisted before the item existed as a pin key) means "never
-// decided", not "unpinned" — the per-item flag below records the user's first
-// explicit pin/unpin, after which STORAGE_KEY alone is authoritative.
-const DEFAULT_PINNED: ToolbarPinKey[] = ["histogram"];
-const PIN_DECIDED_KEYS: Partial> = {
- histogram: "logs_toolbar_histogram_pin_decided",
+const SCOPES: Record = {
+ logs: {
+ storageKey: "logs_toolbar_pinned_items",
+ keys: TOOLBAR_PIN_ORDER,
+ defaultPins: { histogram: "logs_toolbar_histogram_pin_decided" },
+ },
+ traces: {
+ storageKey: "traces_toolbar_pinned_items",
+ keys: ["histogram", "savedViews"],
+ defaultPins: { savedViews: "traces_toolbar_saved_views_pin_decided" },
+ },
};
-const isValidKey = (key: string): key is ToolbarPinKey =>
- (TOOLBAR_PIN_ORDER as string[]).includes(key);
+const scopeStates = new Map>>();
-const readInitial = (): ToolbarPinKey[] => {
+const isDecided = (decidedKey: string): boolean => {
try {
- const raw = window.localStorage.getItem(STORAGE_KEY);
- const parsed = raw ? JSON.parse(raw) : [];
- const pins: ToolbarPinKey[] = Array.isArray(parsed)
- ? parsed.filter((k): k is ToolbarPinKey => typeof k === "string" && isValidKey(k))
- : [];
- for (const key of DEFAULT_PINNED) {
- const decided = window.localStorage.getItem(PIN_DECIDED_KEYS[key]!) === "true";
- if (!decided && !pins.includes(key)) pins.push(key);
- }
- return pins;
+ return window.localStorage.getItem(decidedKey) === "true";
} catch {
- return [...DEFAULT_PINNED];
+ return false;
}
};
-// Module-level state so every component instance stays in sync.
-const pinnedSet = ref>(new Set(readInitial()));
-
-const persist = () => {
+const readStoredPins = (config: ToolbarPinScopeConfig): ToolbarPinKey[] => {
try {
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(pinnedSet.value)));
- } catch (e) {
- // localStorage may be unavailable (private mode / quota) — pins stay in-memory.
- console.log(`Error persisting toolbar pins: ${e}`);
+ const raw = window.localStorage.getItem(config.storageKey);
+ const parsed = raw ? JSON.parse(raw) : [];
+ return Array.isArray(parsed)
+ ? parsed.filter(
+ (k): k is ToolbarPinKey => typeof k === "string" && (config.keys as string[]).includes(k),
+ )
+ : [];
+ } catch {
+ return [];
}
};
-export function useToolbarPins() {
+const readInitial = (config: ToolbarPinScopeConfig): ToolbarPinKey[] => {
+ const pins = readStoredPins(config);
+ for (const [key, decidedKey] of Object.entries(config.defaultPins) as [ToolbarPinKey, string][]) {
+ if (!isDecided(decidedKey) && !pins.includes(key)) pins.push(key);
+ }
+ return pins;
+};
+
+const getScopeState = (scope: ToolbarPinScope): Ref> => {
+ let state = scopeStates.get(scope);
+ if (!state) {
+ state = ref(new Set(readInitial(SCOPES[scope]))) as Ref>;
+ scopeStates.set(scope, state);
+ }
+ return state;
+};
+
+export function useToolbarPins(scope: ToolbarPinScope = "logs") {
+ const config = SCOPES[scope];
+ const pinnedSet = getScopeState(scope);
+
+ const persist = () => {
+ try {
+ window.localStorage.setItem(config.storageKey, JSON.stringify(Array.from(pinnedSet.value)));
+ } catch (e) {
+ // localStorage may be unavailable (private mode / quota) — pins stay in-memory.
+ console.log(`Error persisting toolbar pins: ${e}`);
+ }
+ };
+
const isPinned = (key: ToolbarPinKey): boolean => pinnedSet.value.has(key);
const togglePin = (key: ToolbarPinKey): void => {
+ // A key outside the scope would be dropped by readStoredPins on reload, so never store it.
+ if (!config.keys.includes(key)) return;
const next = new Set(pinnedSet.value);
if (next.has(key)) next.delete(key);
else next.add(key);
pinnedSet.value = next;
- const decidedKey = PIN_DECIDED_KEYS[key];
+ const decidedKey = config.defaultPins[key];
if (decidedKey) {
try {
window.localStorage.setItem(decidedKey, "true");
@@ -93,7 +129,7 @@ export function useToolbarPins() {
// Pinned keys in canonical order (not insertion order).
const pinnedItems = computed(() =>
- TOOLBAR_PIN_ORDER.filter((key) => pinnedSet.value.has(key)),
+ TOOLBAR_PIN_ORDER.filter((key) => config.keys.includes(key) && pinnedSet.value.has(key)),
);
return { isPinned, togglePin, pinnedItems };
diff --git a/web/src/lib/core/Button/OButton.spec.ts b/web/src/lib/core/Button/OButton.spec.ts
index 56836200b3..aa71df259c 100644
--- a/web/src/lib/core/Button/OButton.spec.ts
+++ b/web/src/lib/core/Button/OButton.spec.ts
@@ -196,6 +196,19 @@ describe("OButton", () => {
expect(classes).toContain("w-12");
});
+ it("sizes xs-grouped one border-pair (0.125rem) shorter than xs", () => {
+ expect(mount(OButton, { props: { size: "xs" } }).classes()).toContain("h-7");
+ const classes = mount(OButton, { props: { size: "xs-grouped" } }).classes();
+ expect(classes).toContain("h-6.5");
+ expect(classes).not.toContain("h-7");
+ });
+
+ it("sizes icon-panel as a 1.625rem square, the icon partner of xs-grouped", () => {
+ const classes = mount(OButton, { props: { size: "icon-panel" } }).classes();
+ expect(classes).toContain("size-6.5");
+ expect(classes).toContain("p-0");
+ });
+
// --- Keyboard ---
it("emits click on Enter key (native button behaviour)", async () => {
diff --git a/web/src/lib/core/Button/OButton.types.ts b/web/src/lib/core/Button/OButton.types.ts
index f2e99b7745..a5503e86d3 100644
--- a/web/src/lib/core/Button/OButton.types.ts
+++ b/web/src/lib/core/Button/OButton.types.ts
@@ -85,6 +85,7 @@ export type ButtonSize =
| "sidebar-button"
// 30px labeled button — matches toolbar icon height for labeled outline toolbar buttons
| "sm-toolbar"
+ | "xs-grouped"
// Chip with fixed 12px font — for dashboard query builder axis field chips
| "chip-12";
diff --git a/web/src/lib/core/Button/OButton.vue b/web/src/lib/core/Button/OButton.vue
index 022a9cae8e..1db168c0ba 100644
--- a/web/src/lib/core/Button/OButton.vue
+++ b/web/src/lib/core/Button/OButton.vue
@@ -276,6 +276,7 @@ const sizeClasses: Record, string> = {
sm: "h-[2.125rem] ps-3 pe-3 text-sm gap-2 rounded-default",
// 30px labeled — matches icon-toolbar height for labeled outline buttons in toolbars
"sm-toolbar": "h-[1.875rem] ps-2 pe-2 text-xs gap-1.5 rounded-default",
+ "xs-grouped": "h-6.5 ps-2 pe-2 text-xs gap-1.5 rounded-default",
// Compact labeled size for inline field chips (axis items) — ~28px, matches the dense button size
// Extra-compact chip size — 24px height for axis field chips in query builder
chip: "h-6 ps-2 pe-1.5 text-xs gap-1 rounded-default leading-none",
@@ -302,7 +303,7 @@ const sizeClasses: Record, string> = {
// 30×30px square — for toolbar icon buttons (auto-refresh, share, hamburger)
"icon-toolbar": "size-[1.875rem] shrink-0 p-0 rounded-default gap-x-0",
// 26px rounded-default — compact modern icon button for panel header collapse/expand
- "icon-panel": "size-[1.625rem] shrink-0 p-0 rounded-default gap-x-0",
+ "icon-panel": "size-6.5 shrink-0 p-0 rounded-default gap-x-0",
// Tall narrow vertical rectangle — 32px × 20px for splitter collapse/expand buttons
"sidebar-button": "h-8 w-3 shrink-0 p-0 rounded-default overflow-hidden gap-x-0",
};
diff --git a/web/src/lib/core/Table/OTable.spec.ts b/web/src/lib/core/Table/OTable.spec.ts
index 8501e1c389..51b287eb3c 100644
--- a/web/src/lib/core/Table/OTable.spec.ts
+++ b/web/src/lib/core/Table/OTable.spec.ts
@@ -1340,6 +1340,37 @@ describe("OTable", () => {
const tableEl = wrapper.find('[data-test="o2-table"]');
expect(tableEl.attributes("style")).toContain("2.25rem");
});
+
+ it("uses the compact row token and unpadded cells when compact is true", () => {
+ wrapper = mount(OTable, {
+ props: { data: makeRows(3), columns: makeColumns(), compact: true },
+ });
+ const tableEl = wrapper.find('[data-test="o2-table"]');
+ expect(tableEl.attributes("style")).toContain("var(--table-row-height-compact, 1.5625rem)");
+ const cell = wrapper.find('td[data-test^="o2-table-cell-"]');
+ expect(cell.classes()).toContain("px-0");
+ expect(cell.classes()).not.toContain("px-2");
+ expect(wrapper.find('th[data-test^="o2-table-th-"]').classes()).toContain("h-6");
+ });
+
+ it("keeps padded cells and the default header height without compact", () => {
+ wrapper = mount(OTable, {
+ props: { data: makeRows(3), columns: makeColumns() },
+ });
+ expect(wrapper.find('td[data-test^="o2-table-cell-"]').classes()).toContain("px-2");
+ expect(wrapper.find('th[data-test^="o2-table-th-"]').classes()).toContain("h-8");
+ });
+
+ it("draws the pager hairline by default and drops it with paginationBordered false", async () => {
+ wrapper = mount(OTable, {
+ props: { data: makeRows(5), columns: makeColumns(), pageSize: 2 },
+ });
+ const pager = () => wrapper.find('[data-test="o2-table-pagination-bottom"]');
+ expect(pager().classes()).toContain("border-t");
+
+ await wrapper.setProps({ paginationBordered: false });
+ expect(pager().classes()).not.toContain("border-t");
+ });
});
// ── Pagination: none ───────────────────────────────────────
diff --git a/web/src/lib/core/Table/OTable.types.ts b/web/src/lib/core/Table/OTable.types.ts
index fa047010e9..beadb7f987 100644
--- a/web/src/lib/core/Table/OTable.types.ts
+++ b/web/src/lib/core/Table/OTable.types.ts
@@ -444,6 +444,8 @@ export interface OTableProps {
/** Text shown when data is empty and not loading */
emptyMessage?: I18nText;
dense?: boolean;
+ /** Short rows (`--table-row-height-compact`) with no cell padding, for small list dialogs; overrides `dense`. */
+ compact?: boolean;
bordered?: boolean;
/**
* Draws the outer frame (border) around the whole table region. Default true.
@@ -453,6 +455,8 @@ export interface OTableProps {
frame?: boolean;
/** Draws the hairline divider under the toolbar row (default true); set false when a subheader below would read as a double line. */
toolbarBordered?: boolean;
+ /** Draws the hairline above the built-in pager (default true); set false when the table sits flush in a dialog. */
+ paginationBordered?: boolean;
striped?: boolean;
stickyHeader?: boolean;
showHeader?: boolean;
diff --git a/web/src/lib/core/Table/OTable.vue b/web/src/lib/core/Table/OTable.vue
index 9d81bb9a86..e26ccab046 100644
--- a/web/src/lib/core/Table/OTable.vue
+++ b/web/src/lib/core/Table/OTable.vue
@@ -73,12 +73,14 @@ const props = withDefaults(defineProps>(), {
streaming: false,
error: null,
dense: true,
+ compact: false,
bordered: true,
// No outer box border on tables (design system): the app chrome already frames
// content, and CRUD listing tables sit flush to the edges. Row dividers (the
// `bordered` row-bottom hairlines) stay; only the surrounding border is gone.
frame: false,
toolbarBordered: true,
+ paginationBordered: true,
striped: false,
stickyHeader: true,
wrap: false,
@@ -718,7 +720,7 @@ const {
scrollMargin: props.scrollMargin ?? 0,
// Keep this in sync with the --table-row-height-* tokens (dense = 38px) so the
// virtualizer's measured height matches the actual rendered row height.
- rowHeight: props.rowHeight ?? (props.dense ? 38 : 54),
+ rowHeight: props.rowHeight ?? (props.compact ? 25 : props.dense ? 38 : 54),
overscan: props.overscan ?? 100,
dynamicRowHeight: () => useDynamicRowHeight.value,
// A delegated scroller can contain a histogram or other content before this
@@ -938,6 +940,7 @@ watch(
effectiveColumns,
() => props.loading,
() => props.dense,
+ () => props.compact,
() => frozen.value,
() => measuredColumnSizeVars.value,
() => containerWidth.value,
@@ -1411,9 +1414,11 @@ defineExpose({
'--table-row-height':
props.rowHeight != null
? `${props.rowHeight}px`
- : props.dense
- ? 'var(--table-row-height-dense, 2.25rem)'
- : 'var(--table-row-height-normal, 2.75rem)',
+ : props.compact
+ ? 'var(--table-row-height-compact, 1.5625rem)'
+ : props.dense
+ ? 'var(--table-row-height-dense, 2.25rem)'
+ : 'var(--table-row-height-normal, 2.75rem)',
}"
data-test="o2-table"
:data-test-loading="props.loading ? 'true' : 'false'"
@@ -1445,6 +1450,7 @@ defineExpose({
:sticky-header="props.stickyHeader"
:bordered="props.bordered"
:dense="props.dense"
+ :compact="props.compact"
:pivot-header-levels="props.pivotHeaderLevels"
:pivot-row-columns="props.pivotRowColumns"
:sticky-col-totals="props.stickyColTotals"
@@ -1494,6 +1500,7 @@ defineExpose({
:get-highlighted-html="highlighting.getHighlightedHtml"
:wrap="props.wrap"
:dense="props.dense"
+ :compact="props.compact"
:bordered="props.bordered"
:striped="props.striped"
:row-class="resolvedRowClass as any"
@@ -1751,6 +1758,7 @@ defineExpose({
:is-last-page="pagination.isLastPage.value"
:loading="heldLoading"
:selected-count="selection.selectedCount.value"
+ :bordered="props.paginationBordered"
@update:page-size="pagination.setPageSize"
@first-page="pagination.firstPage"
@prev-page="pagination.prevPage"
@@ -1822,7 +1830,7 @@ defineExpose({
/* keep(lib-override:o2-table-hide-header): public modifier, same shape as the
block above — `thead` is this component's own render, and the class is passed
- in by components/queries/QueryList.vue and plugins/logs/SearchBar.vue (x2). */
+ in by components/queries/QueryList.vue. */
.o2-table-hide-header :deep(thead) {
display: none;
}
diff --git a/web/src/lib/core/Table/sub-components/OTableBody.vue b/web/src/lib/core/Table/sub-components/OTableBody.vue
index 61bf1f2ad3..c40bc6ba4b 100644
--- a/web/src/lib/core/Table/sub-components/OTableBody.vue
+++ b/web/src/lib/core/Table/sub-components/OTableBody.vue
@@ -27,6 +27,7 @@ const props = defineProps<{
getHighlightedHtml?: (columnId: string, cellValue: any) => string | null;
wrap?: boolean;
dense?: boolean;
+ compact?: boolean;
bordered?: boolean;
striped?: boolean;
rowClass?: string | ((row: any) => string);
@@ -241,6 +242,7 @@ const headingColspan = computed(
:get-highlighted-html="getHighlightedHtml"
:wrap="wrap"
:dense="dense"
+ :compact="compact"
:bordered="bordered"
:striped="striped"
:row-class-fn="rowClass"
@@ -304,6 +306,7 @@ const headingColspan = computed(
:get-highlighted-html="getHighlightedHtml"
:wrap="wrap"
:dense="dense"
+ :compact="compact"
:bordered="bordered"
:striped="striped"
:row-class-fn="rowClass"
@@ -371,6 +374,7 @@ const headingColspan = computed(
:get-highlighted-html="getHighlightedHtml"
:wrap="wrap"
:dense="dense"
+ :compact="compact"
:bordered="bordered"
:striped="striped"
:row-class-fn="rowClass"
diff --git a/web/src/lib/core/Table/sub-components/OTableBodyCell.vue b/web/src/lib/core/Table/sub-components/OTableBodyCell.vue
index 6fa8a0cdfb..ccc2ca5a9f 100644
--- a/web/src/lib/core/Table/sub-components/OTableBodyCell.vue
+++ b/web/src/lib/core/Table/sub-components/OTableBodyCell.vue
@@ -56,6 +56,7 @@ const props = defineProps<{
getHighlightedHtml?: (columnId: string, cellValue: any) => string | null;
wrap?: boolean;
dense?: boolean;
+ compact?: boolean;
bordered?: boolean;
enableCellCopy?: boolean;
getCellStyle?: (params: { columnId: string; row: any; value: any }) => Record;
@@ -427,7 +428,7 @@ onBeforeUnmount(() => window.removeEventListener("scroll", onScrollDismiss, true
// instead of falling back to a grey inherited value in dark mode. Inner
// links/badges override this with their own color.
'text-text-body',
- meta?.spacer
+ meta?.spacer || compact
? 'px-0 align-middle'
: meta?.compactPadding
? 'px-1 align-middle'
diff --git a/web/src/lib/core/Table/sub-components/OTableBodyRow.vue b/web/src/lib/core/Table/sub-components/OTableBodyRow.vue
index d4237878b7..dbb93ba7cc 100644
--- a/web/src/lib/core/Table/sub-components/OTableBodyRow.vue
+++ b/web/src/lib/core/Table/sub-components/OTableBodyRow.vue
@@ -29,6 +29,7 @@ const props = defineProps<{
getHighlightedHtml?: (columnId: string, cellValue: any) => string | null;
wrap?: boolean;
dense?: boolean;
+ compact?: boolean;
bordered?: boolean;
striped?: boolean;
rowClassFn?: string | ((row: any) => string);
@@ -273,6 +274,7 @@ function onRowMouseleave() {
:get-highlighted-html="getHighlightedHtml"
:wrap="wrap"
:dense="dense"
+ :compact="compact"
:bordered="bordered"
:enable-cell-copy="enableCellCopy"
:get-cell-style="getCellStyle"
diff --git a/web/src/lib/core/Table/sub-components/OTableHeader.vue b/web/src/lib/core/Table/sub-components/OTableHeader.vue
index e94de49256..5fc1893164 100644
--- a/web/src/lib/core/Table/sub-components/OTableHeader.vue
+++ b/web/src/lib/core/Table/sub-components/OTableHeader.vue
@@ -49,6 +49,7 @@ const props = defineProps<{
pivotRowColumns?: any[];
stickyColTotals?: boolean;
dense?: boolean;
+ compact?: boolean;
/** Show the per-column value-filter dropdown on filterable columns. */
enableColumnFilter?: boolean;
/** Show the per-column "format this column" icon on formattable columns. */
@@ -532,7 +533,7 @@ function getStandardStickyTotalStyle(header: any): Record {
:class="[
`${headerPaddingClass(header)} text-table-header-text relative text-left text-xs font-medium select-none`,
'table-head',
- dense ? 'h-8' : 'h-9',
+ compact ? 'h-6' : dense ? 'h-8' : 'h-9',
'border-table-header-border border-b',
'group',
// Sortable's `filter` selector — this header can't start a drag, so it
@@ -872,7 +873,7 @@ function getStandardStickyTotalStyle(header: any): Record {
:data-test="`o2-table-th-${header.id}`"
:class="[
`${headerPaddingClass(header)} text-table-header-text relative text-left text-xs font-medium select-none`,
- dense ? 'group h-8' : 'group h-9',
+ compact ? 'group h-6' : dense ? 'group h-8' : 'group h-9',
'border-table-header-border border-b',
header.column.getIsPinned?.() ? 'bg-table-header-bg' : '',
(header.column.columnDef.meta as any)?.headerClass ?? '',
diff --git a/web/src/lib/core/Table/sub-components/OTablePagination.vue b/web/src/lib/core/Table/sub-components/OTablePagination.vue
index ac3ab1052b..d357b11826 100644
--- a/web/src/lib/core/Table/sub-components/OTablePagination.vue
+++ b/web/src/lib/core/Table/sub-components/OTablePagination.vue
@@ -28,11 +28,13 @@ const props = withDefaults(
loading?: boolean;
/** Rows currently selected; read only when `#selection-actions` is provided. */
selectedCount?: number;
+ bordered?: boolean;
}>(),
{
position: "bottom",
totalCountExact: true,
selectedCount: 0,
+ bordered: true,
},
);
@@ -75,7 +77,10 @@ const selectionLabel = computed(() => {
.
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
+import i18n from "@/locales";
+import store from "@/test/unit/helpers/store";
+import { createRouter, createMemoryHistory } from "vue-router";
+import SearchBar from "@/plugins/logs/SearchBar.vue";
+import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
+import ConfirmDialog from "@/components/ConfirmDialog.vue";
+
+const { mockSavedViewsGet, mockSavedViewsDelete } = vi.hoisted(() => ({
+ mockSavedViewsGet: vi.fn(),
+ mockSavedViewsDelete: vi.fn(),
+}));
+
+vi.mock("@/services/saved_views", async (importOriginal) => {
+ const { overlayServiceMock } = await import("@/test/unit/helpers/mockService");
+ return overlayServiceMock(await importOriginal(), {
+ default: { get: mockSavedViewsGet, delete: mockSavedViewsDelete },
+ });
+});
+
+// A light in-memory router: the app router lazy-loads real pages on navigation.
+const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: "/logs", name: "logs", component: { template: "
" } }],
+});
+
+const logsView = {
+ view_id: "l1",
+ view_name: "error logs",
+ org_id: "default",
+ view_type: "logs",
+};
+const logsView2 = {
+ view_id: "l2",
+ view_name: "slow requests",
+ org_id: "default",
+ view_type: "logs",
+};
+
+describe("SearchBar — saved views list dialog", () => {
+ let wrapper: VueWrapper
| undefined;
+
+ const mountSearchBar = () =>
+ mount(SearchBar, {
+ global: {
+ provide: { store },
+ plugins: [i18n, router],
+ stubs: { QueryEditor: true },
+ },
+ });
+
+ const dialog = () => wrapper!.findComponent(SavedViewsListDialog);
+ const favorites = () => JSON.parse(localStorage.getItem("savedViews") || "{}");
+
+ beforeEach(() => {
+ localStorage.removeItem("savedViews");
+ mockSavedViewsGet.mockResolvedValue({ data: { views: [logsView, logsView2] } });
+ mockSavedViewsDelete.mockResolvedValue({ status: 200, data: {} });
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = undefined;
+ localStorage.removeItem("savedViews");
+ vi.clearAllMocks();
+ });
+
+ it("passes the logs data-test prefix, views and favourites to the dialog", async () => {
+ localStorage.setItem("savedViews", JSON.stringify({ l1: logsView }));
+ wrapper = mountSearchBar();
+ wrapper.vm.searchObj.data.savedViews = [logsView, logsView2];
+ await flushPromises();
+
+ expect(dialog().props("dataTestPrefix")).toBe("logs-saved-views-dialog");
+ expect(dialog().props("views")).toEqual([logsView, logsView2]);
+ expect(dialog().props("favoriteIds")).toEqual(["l1"]);
+ expect(dialog().props("favoriteViews")).toEqual([logsView]);
+ });
+
+ it("toggles a favourite from the dialog", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ dialog().vm.$emit("toggle-favorite", logsView, false);
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toEqual(["l1"]);
+ expect(Object.keys(favorites())).toEqual(["l1"]);
+
+ dialog().vm.$emit("toggle-favorite", logsView, true);
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toEqual([]);
+ expect(favorites()).toEqual({});
+ });
+
+ it("a confirmed delete from the dialog drops the favourite", async () => {
+ localStorage.setItem("savedViews", JSON.stringify({ l1: logsView }));
+ wrapper = mountSearchBar();
+ await flushPromises();
+ wrapper.vm.savedViewsListDialog = true;
+ await flushPromises();
+
+ dialog().vm.$emit("delete", logsView);
+ await flushPromises();
+
+ expect(dialog().props("open")).toBe(false);
+ const confirm = wrapper
+ .findAllComponents(ConfirmDialog)
+ .find((c) => c.props("modelValue") === true)!;
+ expect(confirm.props("title")).toBe(i18n.global.t("search.deleteSavedView"));
+ expect(mockSavedViewsDelete).not.toHaveBeenCalled();
+
+ confirm.vm.$emit("update:ok");
+ await flushPromises();
+
+ expect(mockSavedViewsDelete).toHaveBeenCalledWith("default", "l1");
+ expect(dialog().props("favoriteIds")).toEqual([]);
+ expect(favorites()).toEqual({});
+ }, 20000);
+
+ it("prunes favourites deleted elsewhere once the list loads, freeing the cap", async () => {
+ const stored: Record = {
+ t1: { ...logsView, view_id: "t1", view_type: "traces" },
+ };
+ for (let i = 0; i < 9; i++) stored[`gone${i}`] = { ...logsView, view_id: `gone${i}` };
+ stored.l1 = logsView;
+ localStorage.setItem("savedViews", JSON.stringify(stored));
+ wrapper = mountSearchBar();
+ wrapper.vm.searchObj.data.savedViews = [];
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toHaveLength(10);
+
+ wrapper.vm.openSavedViewsList();
+ await flushPromises();
+
+ expect(mockSavedViewsGet).toHaveBeenCalledTimes(1);
+ expect(dialog().props("favoriteIds")).toEqual(["l1"]);
+ expect(Object.keys(favorites())).toEqual(["t1", "l1"]);
+
+ dialog().vm.$emit("toggle-favorite", logsView2, false);
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toEqual(["l1", "l2"]);
+ });
+
+ it("keeps every favourite when the list fails to load", async () => {
+ mockSavedViewsGet.mockRejectedValue(new Error("boom"));
+ const gone = { ...logsView, view_id: "gone" };
+ localStorage.setItem("savedViews", JSON.stringify({ l1: logsView, gone }));
+ wrapper = mountSearchBar();
+ wrapper.vm.searchObj.data.savedViews = [];
+ await flushPromises();
+
+ wrapper.vm.openSavedViewsList();
+ await flushPromises();
+
+ expect(mockSavedViewsGet).toHaveBeenCalledTimes(1);
+ expect(dialog().props("loading")).toBe(false);
+ expect(dialog().props("views")).toEqual([]);
+ expect(dialog().props("favoriteIds")).toEqual(["l1", "gone"]);
+ expect(favorites()).toEqual({ l1: logsView, gone });
+ });
+});
diff --git a/web/src/plugins/logs/SearchBar.savedViewsPinned.spec.ts b/web/src/plugins/logs/SearchBar.savedViewsPinned.spec.ts
new file mode 100644
index 0000000000..38a3c7e52c
--- /dev/null
+++ b/web/src/plugins/logs/SearchBar.savedViewsPinned.spec.ts
@@ -0,0 +1,114 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mount, flushPromises, VueWrapper } from "@vue/test-utils";
+import i18n from "@/locales";
+import store from "@/test/unit/helpers/store";
+import { createRouter, createMemoryHistory } from "vue-router";
+import SearchBar from "@/plugins/logs/SearchBar.vue";
+import { useToolbarPins } from "@/composables/useToolbarPins";
+
+// A light in-memory router: the app router lazy-loads real pages on navigation.
+const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [{ path: "/logs", name: "logs", component: { template: "" } }],
+});
+
+describe("SearchBar — pinned saved views", () => {
+ let wrapper: VueWrapper | undefined;
+ const { isPinned, togglePin } = useToolbarPins();
+
+ // jsdom reports zero widths, so every pinned item would fall back into the More menu.
+ const mountSearchBar = (toolbarWidth = 0) => {
+ if (toolbarWidth) {
+ vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: HTMLElement,
+ ) {
+ const isToolbarBar = this.classList.contains("border-b-card-glass-border");
+ return { width: isToolbarBar ? toolbarWidth : 0 } as DOMRect;
+ });
+ }
+ return mount(SearchBar, {
+ global: {
+ provide: { store },
+ plugins: [i18n, router],
+ stubs: { QueryEditor: true },
+ },
+ });
+ };
+
+ const group = () => wrapper!.find('[data-test="logs-search-bar-saved-views-pinned"]');
+
+ beforeEach(() => {
+ if (!isPinned("savedViews")) togglePin("savedViews");
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = undefined;
+ if (isPinned("savedViews")) togglePin("savedViews");
+ vi.restoreAllMocks();
+ });
+
+ it("gives the list trigger the grouped xs size with an icon and a caret", async () => {
+ wrapper = mountSearchBar(1600);
+ await flushPromises();
+
+ const trigger = group()
+ .findAllComponents({ name: "OButton" })
+ .find((b) => b.attributes("data-test") === "logs-search-bar-saved-views-pinned-list-btn")!;
+ expect(trigger.props("size")).toBe("xs-grouped");
+ const icons = trigger.findAllComponents({ name: "OIcon" }).map((c) => c.props("name"));
+ expect(icons).toEqual(["saved-search", "arrow-drop-down"]);
+ });
+
+ it("divides the list trigger from the save button with a vertical separator", async () => {
+ wrapper = mountSearchBar(1600);
+ await flushPromises();
+
+ expect(group().exists()).toBe(true);
+ const separator = group().findComponent({ name: "OSeparator" });
+ expect(separator.exists()).toBe(true);
+ expect(separator.props("vertical")).toBe(true);
+ expect(
+ group().find('[data-test="logs-search-bar-saved-views-pinned-create-btn"]').exists(),
+ ).toBe(true);
+ });
+
+ it("is as tall as the xs toolbar buttons: a hairline border around 1.625rem children", async () => {
+ wrapper = mountSearchBar(1600);
+ await flushPromises();
+
+ expect(group().classes()).toContain("border");
+ expect(group().classes()).toContain("p-0");
+ expect(
+ group().find('[data-test="logs-search-bar-saved-views-pinned-list-btn"]').classes(),
+ ).toContain("h-6.5");
+ expect(
+ group().find('[data-test="logs-search-bar-saved-views-pinned-create-btn"]').classes(),
+ ).toContain("size-6.5");
+ expect(wrapper!.find('[data-test="logs-search-bar-reset-filters-btn"]').classes()).toContain(
+ "h-7",
+ );
+ });
+
+ it("moves the group into the More menu when the toolbar has no room", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(group().exists()).toBe(false);
+ });
+});
diff --git a/web/src/plugins/logs/SearchBar.spec.ts b/web/src/plugins/logs/SearchBar.spec.ts
index 1c39127331..dac749458a 100644
--- a/web/src/plugins/logs/SearchBar.spec.ts
+++ b/web/src/plugins/logs/SearchBar.spec.ts
@@ -1796,12 +1796,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
}
}),
- filterSavedViewFn: vi.fn((rows, terms) => {
- if (terms === "") return [];
- terms = terms.toLowerCase();
- return rows.filter((row) => row.view_name.toLowerCase().includes(terms));
- }),
-
regionFilterMethod: vi.fn((node, filter) => {
const filt = filter.toLowerCase();
return (node && node.label && node.label.toLowerCase().indexOf(filt) > -1) || false;
@@ -2185,21 +2179,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
});
});
- // Test 100: filterSavedViewFn method
- it("should filter saved views by search terms", () => {
- const rows = [
- { view_name: "My Test View" },
- { view_name: "Production Logs" },
- { view_name: "Test Results" },
- ];
-
- const filtered = componentInstance.filterSavedViewFn(rows, "test");
-
- expect(filtered).toHaveLength(2);
- expect(filtered[0].view_name).toBe("My Test View");
- expect(filtered[1].view_name).toBe("Test Results");
- });
-
// Test 101: regionFilterMethod
it("should filter regions by label", () => {
const node = { label: "US East" };
@@ -2678,24 +2657,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
});
});
- // Test 142: filterSavedViewFn with empty search terms
- it("should return empty array for empty search terms", () => {
- const rows = [{ view_name: "Test View" }];
-
- const result = componentInstance.filterSavedViewFn(rows, "");
-
- expect(result).toEqual([]);
- });
-
- // Test 143: filterSavedViewFn with no matches
- it("should return empty array when no views match", () => {
- const rows = [{ view_name: "Production Logs" }, { view_name: "Error Tracking" }];
-
- const result = componentInstance.filterSavedViewFn(rows, "development");
-
- expect(result).toEqual([]);
- });
-
// Test 144: resetRegionFilter method
it("should reset region filter to empty string", () => {
componentInstance.regionFilter = "us-east";
@@ -3419,20 +3380,6 @@ describe("SearchBar.vue Actual Component Methods", () => {
expect(componentInstance.searchObj.data.customDownloadQueryObj.query.size).toBe(100);
});
- // Test 208: filterSavedViewFn with special characters
- it("should filter views with special characters", () => {
- const rows = [
- { view_name: "Test-View_123" },
- { view_name: "Production@Logs" },
- { view_name: "Dev Environment" },
- ];
-
- const result = componentInstance.filterSavedViewFn(rows, "-");
-
- expect(result).toHaveLength(1);
- expect(result[0].view_name).toBe("Test-View_123");
- });
-
// Test 209: regionFilterMethod with empty filter
it("should handle empty filter in region method", () => {
const node = { label: "US East" };
diff --git a/web/src/plugins/logs/SearchBar.vue b/web/src/plugins/logs/SearchBar.vue
index 973c12b35c..6aacf761e4 100644
--- a/web/src/plugins/logs/SearchBar.vue
+++ b/web/src/plugins/logs/SearchBar.vue
@@ -239,7 +239,7 @@ along with this program. If not, see .
@@ -310,7 +310,7 @@ along with this program. If not, see .
@@ -1719,240 +1719,19 @@ along with this program. If not, see .
-
-
-
-
+ v-model:search="searchObj.data.savedViewFilterFields"
+ data-test-prefix="logs-saved-views-dialog"
+ :views="searchObj.data.savedViews"
+ :favorite-ids="favoriteViews"
+ :favorite-views="localSavedViews"
+ :loading="searchObj.loadingSavedView == true"
+ @apply="applySavedView"
+ @update="handleUpdateSavedView"
+ @delete="handleDeleteSavedView"
+ @toggle-favorite="handleFavoriteSavedView"
+ />
@@ -1982,7 +1761,7 @@ import {
toRef,
computed,
} from "vue";
-import { useI18nTyped, raw } from "@/types/i18n";
+import { useI18nTyped } from "@/types/i18n";
import { useRouter } from "vue-router";
import { useStore } from "vuex";
import { useTheme } from "@/composables/useTheme";
@@ -1990,7 +1769,6 @@ import useBreakpoint from "@/composables/useBreakpoint";
import DateTime from "@/components/DateTime.vue";
import ShareButton from "@/components/common/ShareButton.vue";
import OButton from "@/lib/core/Button/OButton.vue";
-import OTable from "@/lib/core/Table/OTable.vue";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OSplitter from "@/lib/core/Splitter/OSplitter.vue";
import useLogs from "@/composables/useLogs";
@@ -2014,7 +1792,6 @@ import {
b64DecodeUnicode,
getImageURL,
useLocalInterestingFields,
- useLocalSavedView,
queryIndexSplit,
timestampToTimezoneDate,
b64EncodeUnicode,
@@ -2066,7 +1843,6 @@ import { hasFieldCondition, removeFieldCondition } from "@/plugins/logs/filterUt
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OInput from "@/lib/forms/Input/OInput.vue";
-import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OForm from "@/lib/forms/Form/OForm.vue";
@@ -2081,7 +1857,9 @@ import type { AlertBuildOptions } from "@/ts/interfaces/alertPrefill";
import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import OTree from "@/lib/data/Tree/OTree.vue";
import { makeSavedViewSchema, type SavedViewForm } from "./SearchBar.SavedView.schema";
-import { sortSavedViews } from "./savedViewsSort";
+import { sortSavedViews } from "@/components/savedViews/savedViewsSort";
+import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
+import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews";
import { makeSavedFunctionSchema, type SavedFunctionForm } from "./SearchBar.SavedFunction.schema";
const defaultValue: any = () => {
@@ -2165,14 +1943,13 @@ export default defineComponent({
OSpinner,
OTooltip,
OInput,
- OSearchInput,
OSelect,
OForm,
OFormInput,
OFormSelect,
OSwitch,
OTree,
- OTable,
+ SavedViewsListDialog,
},
emits: [
"searchdata",
@@ -2305,24 +2082,6 @@ export default defineComponent({
);
const { isDark } = useTheme();
const { showErrorNotification } = useNotifications();
- const rowsPerPage = ref(10);
- const savedViewColumns = [
- {
- id: "view_name",
- header: raw(""),
- accessorKey: "view_name",
- sortable: false,
- meta: { align: "left" },
- },
- {
- id: "actions",
- header: raw(""),
- isAction: true,
- sortable: false,
- size: 30,
- meta: { align: "right" },
- },
- ];
const regionFilter = ref();
const regionFilterRef = ref(null);
const { resetStreamData, searchObj } = searchState();
@@ -2476,21 +2235,13 @@ export default defineComponent({
let streamName = "";
const dateTimeRef = ref(null);
- const favoriteViews = ref([]);
-
- const localSavedViews = ref([]);
- let savedViews = useLocalSavedView();
- if (savedViews.value != null) {
- const favoriteValues = [];
- Object.values(savedViews.value).forEach((view) => {
- if (view.org_id === store.state.selectedOrganization.identifier) {
- favoriteViews.value.push(view.view_id);
- favoriteValues.push(view);
- }
- });
-
- localSavedViews.value.push(...favoriteValues);
- }
+ const {
+ favoriteIds: favoriteViews,
+ favoriteViews: localSavedViews,
+ toggleFavorite: handleFavoriteSavedView,
+ removeFavorite,
+ pruneFavorites,
+ } = useFavoriteSavedViews("logs");
const {
autoCompleteData,
@@ -4084,27 +3835,13 @@ export default defineComponent({
deleteSavedView
.mutateAsync(deleteViewID.value)
.then((res: any) => {
- //remove it from localstorage as well
- const localStoredSavedViews = JSON.parse(localStorage.getItem("savedViews") || "[]");
- delete localStoredSavedViews[deleteViewID.value];
- favoriteViews.value.forEach((item: any) => {
- //remove it from favorite views list because we dont need to show it in the favorite views list
- if (item == deleteViewID.value) {
- favoriteViews.value.splice(favoriteViews.value.indexOf(item), 1);
- }
- });
- //remove it from local saved views list because we dont need to show it in the local saved views list
- localSavedViews.value = localSavedViews.value.filter(
- (item: any) => item.view_id !== deleteViewID.value,
- );
- localStorage.setItem("savedViews", JSON.stringify(localStoredSavedViews));
- //we are deleting the local storage item and also we are removing the item from the favoriteViews array
+ removeFavorite(deleteViewID.value);
if (res.status == 200) {
toast({
message: t("search.viewDeletedSuccessfully"),
variant: "success",
});
- getSavedViews();
+ refreshSavedViews();
} else {
toast({
message: t("search.errorDeletingSavedViewDetail", {
@@ -4219,7 +3956,7 @@ export default defineComponent({
message: t("search.viewCreatedSuccessfully"),
variant: "success",
});
- getSavedViews();
+ refreshSavedViews();
isSavedViewAction.value = "create";
} else {
toast({
@@ -4442,111 +4179,18 @@ export default defineComponent({
{ label: "JSON", value: "json" },
]);
+ // Only a successful fetch is the complete list; pruning on error would drop every favourite.
+ const refreshSavedViews = async () => {
+ const views = await getSavedViews();
+ if (views) pruneFavorites(views.map((view) => view.view_id));
+ };
+
const loadSavedView = () => {
if (searchObj.data.savedViews.length == 0) {
- getSavedViews();
+ refreshSavedViews();
}
};
- const filteredSavedViews = computed(() => {
- const filter = (searchObj.data.savedViewFilterFields ?? "").toLowerCase();
- if (!filter) return searchObj.data.savedViews;
- return searchObj.data.savedViews.filter((v: any) =>
- (v.view_name ?? "").toLowerCase().includes(filter),
- );
- });
-
- const savedViewPage = ref(1);
- const savedViewPageSize = ref(10);
-
- const savedViewTotalPages = computed(() =>
- Math.ceil(filteredSavedViews.value.length / savedViewPageSize.value),
- );
-
- const paginatedSavedViews = computed(() => {
- const start = (savedViewPage.value - 1) * savedViewPageSize.value;
- return filteredSavedViews.value.slice(start, start + savedViewPageSize.value);
- });
-
- watch(filteredSavedViews, () => {
- savedViewPage.value = 1;
- });
-
- const handleFavoriteSavedView = (row: any, flag: boolean) => {
- let localSavedView: any = {};
- let savedViews = useLocalSavedView();
-
- if (savedViews.value != null) {
- localSavedView = savedViews.value;
- }
-
- Object.keys(localSavedView).forEach((item) => {
- if (item == row.view_id) {
- if (flag) {
- delete localSavedView[item];
- useLocalSavedView(localSavedView);
- const index = favoriteViews.value.indexOf(row.view_id);
- if (index > -1) {
- favoriteViews.value.splice(index, 1);
- }
-
- let favoriteViewsList = localSavedViews.value;
- if (favoriteViewsList.length > 0) {
- favoriteViewsList = favoriteViewsList.filter((item) => item.view_id != row.view_id);
- // for (const [key, item] of favoriteViewsList.entries()) {
- // console.log(item, key);
- // if (item.view_id == row.view_id) {
- // delete favoriteViewsList[key];
- // }
- // }
- localSavedViews.value = favoriteViewsList;
- }
- }
- }
- });
-
- if (!flag) {
- if (favoriteViews.value.length >= 10) {
- toast({
- message: t("logs.searchBar.maxViewsLimit"),
- variant: "warning",
- });
- return;
- }
- localSavedView[row.view_id] = JSON.parse(JSON.stringify(row));
- favoriteViews.value = [...favoriteViews.value, row.view_id];
- localSavedViews.value = [...localSavedViews.value, row];
-
- // moveItemsToTop(localSavedView, favoriteViews.value);
-
- useLocalSavedView(localSavedView);
- toast({
- message: t("logs.searchBar.viewAddedFavorites"),
- variant: "success",
- });
- } else {
- // alert(favoriteViews.value.length)
- // moveItemsToTop(localSavedView, favoriteViews.value);
- toast({
- message: t("logs.searchBar.viewRemovedFavorites"),
- variant: "success",
- });
- }
- };
-
- const filterSavedViewFn = (rows: any, terms: any) => {
- var filtered = [];
- if (terms != "") {
- terms = terms.toLowerCase();
- for (var i = 0; i < rows.length; i++) {
- if (rows[i]["view_name"].toLowerCase().includes(terms)) {
- filtered.push(rows[i]);
- }
- }
- }
- return filtered;
- };
-
const regionFilterMethod = (node, filter) => {
const filt = filter.toLowerCase();
return node.label && node.label.toLowerCase().indexOf(filt) > -1;
@@ -5163,18 +4807,10 @@ export default defineComponent({
downloadCustomRangeOptions,
buildSearch,
confirmSavedViewDialogVisible,
- rowsPerPage,
handleFavoriteSavedView,
favoriteViews,
localSavedViews,
loadSavedView,
- filterSavedViewFn,
- filteredSavedViews,
- savedViewPage,
- savedViewPageSize,
- savedViewTotalPages,
- paginatedSavedViews,
- savedViewColumns,
config,
handleRegionsSelection,
handleQuickMode,
@@ -5501,43 +5137,6 @@ export default defineComponent({
diff --git a/web/src/plugins/traces/SearchBar.spec.ts b/web/src/plugins/traces/SearchBar.spec.ts
index bec1bf1bb7..c3cb08de3c 100644
--- a/web/src/plugins/traces/SearchBar.spec.ts
+++ b/web/src/plugins/traces/SearchBar.spec.ts
@@ -131,6 +131,19 @@ vi.mock("@/composables/useConfirmDialog", () => ({
useConfirmDialog: () => ({ confirm: mockConfirm }),
}));
+// Read at setup time, so set it before mounting.
+const breakpointState = vi.hoisted(() => ({ lgUp: true }));
+
+vi.mock("@/composables/useBreakpoint", () => ({
+ default: () => ({
+ isMobile: { value: false },
+ isTablet: { value: !breakpointState.lgUp },
+ isDesktop: { value: breakpointState.lgUp },
+ mdUp: { value: true },
+ lgUp: { value: breakpointState.lgUp },
+ }),
+}));
+
// ---------------------------------------------------------------------------
// Shared mutable searchObj — reset to a fresh copy in every beforeEach so
// that mutations in one test cannot affect the next.
@@ -280,6 +293,8 @@ vi.mock("@/composables/useTraces", () => ({
// Import the component AFTER all vi.mock declarations.
// ---------------------------------------------------------------------------
import SearchBar from "@/plugins/traces/SearchBar.vue";
+import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
+import { useToolbarPins } from "@/composables/useToolbarPins";
// ---------------------------------------------------------------------------
// DOM anchor node required by attachTo
@@ -370,7 +385,9 @@ const sharedStubs = {
'
',
emits: ["select"],
},
- ODropdownGroup: { template: "
" },
+ ODropdownGroup: {
+ template: '
',
+ },
ODropdownSeparator: { template: "
" },
};
@@ -392,6 +409,15 @@ function mountSearchBar(props: Record = {}): VueWrapper {
});
}
+// jsdom reports zero widths, so the pinned saved-views group would always fall back into More.
+function mockToolbarWidth(width: number) {
+ return vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: HTMLElement,
+ ) {
+ return { width: this.classList.contains("justify-between") ? width : 0 } as DOMRect;
+ });
+}
+
// Minimal RFC 4180 reader so the test checks column alignment independently of the writer.
function csvToRows(csv: string): string[][] {
const rows: string[][] = [];
@@ -733,6 +759,182 @@ describe("SearchBar", () => {
);
});
+ // -------------------------------------------------------------------------
+ describe("RED Metrics pin", () => {
+ const { isPinned, togglePin } = useToolbarPins("traces");
+ let widthSpy: ReturnType;
+
+ const pinBtn = () => wrapper.find('[data-test="traces-search-bar-menu-pin-metrics-btn"]');
+ const pinnedBtn = () => wrapper.find('[data-test="traces-search-bar-metrics-pinned-btn"]');
+ const savedViewsGroup = () => wrapper.find('[data-test="traces-search-bar-saved-views"]');
+ const menuItem = () => wrapper.find('[data-test="traces-search-bar-menu-metrics-btn"]');
+ const resetPins = () => {
+ if (isPinned("histogram")) togglePin("histogram");
+ if (!isPinned("savedViews")) togglePin("savedViews");
+ };
+ const useWidth = (width: number) => {
+ widthSpy.mockRestore();
+ widthSpy = mockToolbarWidth(width);
+ };
+
+ beforeEach(() => {
+ mockSavedViewsGet.mockResolvedValue({ data: { views: [] } });
+ searchObjInstance.meta.searchMode = "spans";
+ breakpointState.lgUp = true;
+ resetPins();
+ widthSpy = mockToolbarWidth(1600);
+ });
+
+ afterEach(() => {
+ widthSpy.mockRestore();
+ breakpointState.lgUp = true;
+ resetPins();
+ });
+
+ it("offers a pin in the More item and no toolbar copy by default", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinBtn().exists()).toBe(true);
+ expect(pinBtn().attributes("title")).toBe("search.pinToToolbar");
+ expect(pinBtn().findComponent({ name: "OIcon" }).props("name")).toBe("keep-outline");
+ expect(pinnedBtn().exists()).toBe(false);
+ });
+
+ it("pins without selecting the menu item, and unpins", async () => {
+ searchObjInstance.meta.showHistogram = true;
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ const item = wrapper
+ .findAllComponents({ name: "ODropdownItem" })
+ .find((c) => c.attributes("data-test") === "traces-search-bar-menu-metrics-btn")!;
+ await pinBtn().trigger("click");
+ await flushPromises();
+
+ expect(item.emitted("select")).toBeUndefined();
+ expect(searchObjInstance.meta.showHistogram).toBe(true);
+ expect(pinnedBtn().exists()).toBe(true);
+ expect(pinBtn().attributes("title")).toBe("search.unpinFromToolbar");
+ expect(pinBtn().findComponent({ name: "OIcon" }).props("name")).toBe("keep");
+ expect(menuItem().exists()).toBe(true);
+
+ await pinBtn().trigger("click");
+ await flushPromises();
+ expect(pinnedBtn().exists()).toBe(false);
+ });
+
+ it("flips showHistogram from the pinned button and its switch exactly once", async () => {
+ searchObjInstance.meta.showHistogram = true;
+ togglePin("histogram");
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ await pinnedBtn().trigger("click");
+ await flushPromises();
+ expect(searchObjInstance.meta.showHistogram).toBe(false);
+
+ await pinnedBtn().find('[role="switch"]').trigger("click");
+ await flushPromises();
+ expect(searchObjInstance.meta.showHistogram).toBe(true);
+ });
+
+ it("keeps a single show-metrics toggle data-test while pinned", async () => {
+ togglePin("histogram");
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinnedBtn().exists()).toBe(true);
+ expect(
+ wrapper.findAll('[data-test="traces-search-bar-show-metrics-toggle-btn"]'),
+ ).toHaveLength(1);
+ });
+
+ it.each(["service-graph", "services-catalog"] as const)(
+ "hides the pinned copy on the %s tab",
+ async (mode) => {
+ searchObjInstance.meta.searchMode = mode;
+ togglePin("histogram");
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinnedBtn().exists()).toBe(false);
+ },
+ );
+
+ it("shows the pinned copy below lg without a width budget", async () => {
+ useWidth(0);
+ breakpointState.lgUp = false;
+ togglePin("histogram");
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinnedBtn().exists()).toBe(true);
+ expect(savedViewsGroup().exists()).toBe(true);
+ });
+
+ it("falls back to More at a narrow lg width", async () => {
+ useWidth(300);
+ togglePin("histogram");
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinnedBtn().exists()).toBe(false);
+ expect(menuItem().exists()).toBe(true);
+ });
+
+ it.each([
+ [359, false],
+ [360, true],
+ ])(
+ "admits the pinned copy at width %i only when its full width fits: %s",
+ async (width, shown) => {
+ togglePin("savedViews");
+ togglePin("histogram");
+ useWidth(width);
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinnedBtn().exists()).toBe(shown);
+ expect(menuItem().exists()).toBe(true);
+ },
+ );
+
+ it("keeps RED Metrics before Saved Views when only one fits", async () => {
+ useWidth(400);
+ wrapper = mountSearchBar();
+ await flushPromises();
+ expect(savedViewsGroup().exists()).toBe(true);
+
+ togglePin("histogram");
+ await flushPromises();
+ expect(pinnedBtn().exists()).toBe(true);
+ expect(savedViewsGroup().exists()).toBe(false);
+ expect(wrapper.find('[data-test="traces-search-bar-menu-saved-views-group"]').exists()).toBe(
+ true,
+ );
+ });
+
+ it("shrinks the toggle labels for the pinned RED Metrics width", async () => {
+ togglePin("savedViews");
+ togglePin("histogram");
+ useWidth(800);
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(pinnedBtn().exists()).toBe(true);
+ expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).not.toContain(
+ "traces.spansTab",
+ );
+
+ togglePin("histogram");
+ await flushPromises();
+ expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).toContain(
+ "traces.spansTab",
+ );
+ });
+ });
+
// -------------------------------------------------------------------------
describe("Drill down button", () => {
const applySearch = () => {
@@ -1512,9 +1714,23 @@ describe("SearchBar", () => {
});
describe("saved views", () => {
- const tracesView = { view_id: "t1", view_name: "checkout errors", view_type: "traces" };
+ const tracesView = {
+ view_id: "t1",
+ view_name: "checkout errors",
+ view_type: "traces",
+ org_id: "default",
+ };
+ const tracesView2 = {
+ view_id: "t2",
+ view_name: "Alpha latency",
+ view_type: "traces",
+ org_id: "default",
+ };
const logsView = { view_id: "l1", view_name: "logs view" };
+ const { isPinned, togglePin } = useToolbarPins("traces");
+ let widthSpy: ReturnType;
+
const expectedData = () => ({
version: 1,
stream: { label: "default", value: "default" },
@@ -1546,6 +1762,17 @@ describe("SearchBar", () => {
searchObjInstance.meta.resultGrid.sortBy = "duration";
searchObjInstance.meta.resultGrid.sortOrder = "asc";
searchObjInstance.data.stream.selectedFields = ["service_name", "duration"];
+
+ breakpointState.lgUp = true;
+ if (!isPinned("savedViews")) togglePin("savedViews");
+ widthSpy = mockToolbarWidth(1600);
+ localStorage.removeItem("savedViews");
+ });
+
+ afterEach(() => {
+ widthSpy.mockRestore();
+ breakpointState.lgUp = true;
+ if (!isPinned("savedViews")) togglePin("savedViews");
});
it("lists only traces views", async () => {
@@ -1588,6 +1815,27 @@ describe("SearchBar", () => {
expect(separator.props("vertical")).toBe(true);
});
+ it("is as tall as Reset and More: a hairline border around 1.625rem children", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ const group = wrapper.find('[data-test="traces-search-bar-saved-views"]');
+ expect(group.classes()).toContain("border");
+ expect(group.classes()).toContain("p-0");
+ expect(group.find('[data-test="traces-search-bar-saved-views-btn"]').classes()).toContain(
+ "h-6.5",
+ );
+ expect(
+ group.find('[data-test="traces-search-bar-saved-views-create-btn"]').classes(),
+ ).toContain("size-6.5");
+ for (const neighbour of [
+ "traces-search-bar-reset-filters-btn",
+ "traces-search-bar-more-menu-btn",
+ ]) {
+ expect(wrapper.find(`[data-test="${neighbour}"]`).classes()).toContain("h-7");
+ }
+ });
+
it.each(["service-graph", "services-catalog"])("is hidden on the %s tab", async (mode) => {
searchObjInstance.meta.searchMode = mode as any;
wrapper = mountSearchBar();
@@ -1660,26 +1908,375 @@ describe("SearchBar", () => {
expect(wrapper.emitted("apply-saved-view")).toBeUndefined();
});
- it("delete calls the delete mutation once confirmed", async () => {
+ it("pinned dropdown has no delete action", async () => {
wrapper = mountSearchBar();
await flushPromises();
- await wrapper.find('[data-test="traces-saved-view-delete-t1"]').trigger("click");
- await flushPromises();
-
- expect(mockConfirm).toHaveBeenCalledTimes(1);
- expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1");
+ expect(wrapper.find('[data-test="traces-saved-view-update-t1"]').exists()).toBe(true);
+ expect(wrapper.find('[data-test="traces-saved-view-delete-t1"]').exists()).toBe(false);
});
- it("delete does nothing when not confirmed", async () => {
- mockConfirm.mockResolvedValue(false);
+ it("pinned dropdown lists favourites first, then by name, with a star on favourites", async () => {
+ mockSavedViewsGet.mockResolvedValue({ data: { views: [tracesView, tracesView2, logsView] } });
+ localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView }));
wrapper = mountSearchBar();
await flushPromises();
- await wrapper.find('[data-test="traces-saved-view-delete-t1"]').trigger("click");
+ const items = wrapper
+ .findAll('[data-test^="traces-saved-view-apply-"]')
+ .map((item) => item.attributes("data-test"));
+ expect(items).toEqual(["traces-saved-view-apply-t1", "traces-saved-view-apply-t2"]);
+ const iconName = (id: string) =>
+ wrapper
+ .findAllComponents({ name: "OIcon" })
+ .find((c) => c.attributes("data-test") === `traces-saved-view-icon-${id}`)
+ ?.props("name");
+ expect(iconName("t1")).toBe("star");
+ expect(iconName("t2")).toBe("saved-search");
+ });
+
+ it("pinned dropdown shows a loading row while the list loads", async () => {
+ mockSavedViewsGet.mockReturnValue(new Promise(() => {}));
+ wrapper = mountSearchBar();
await flushPromises();
- expect(mockSavedViewsDelete).not.toHaveBeenCalled();
+ expect(wrapper.find('[data-test="traces-saved-views-menu-loading"]').exists()).toBe(true);
+ expect(wrapper.findComponent(SavedViewsListDialog).props("loading")).toBe(true);
+ });
+
+ describe("in the More menu", () => {
+ const moreGroup = () =>
+ wrapper.find('[data-test="traces-search-bar-menu-saved-views-group"]');
+ const toolbarGroup = () => wrapper.find('[data-test="traces-search-bar-saved-views"]');
+ const dialog = () => wrapper.findComponent(SavedViewsListDialog);
+
+ it("shows the saved-views group with List, Create and the pin button", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(moreGroup().exists()).toBe(true);
+ expect(
+ moreGroup().find('[data-test="traces-search-bar-menu-list-saved-views-btn"]').exists(),
+ ).toBe(true);
+ expect(
+ moreGroup().find('[data-test="traces-search-bar-menu-create-saved-view-btn"]').exists(),
+ ).toBe(true);
+ expect(
+ moreGroup().find('[data-test="traces-search-bar-menu-pin-saved-views-btn"]').exists(),
+ ).toBe(true);
+ expect(moreGroup().find('[data-test="traces-saved-view-apply-t1"]').exists()).toBe(false);
+ });
+
+ it("opens the saved-views dialog from List saved views", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+ expect(dialog().props("open")).toBe(false);
+
+ await moreGroup()
+ .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]')
+ .trigger("click");
+
+ expect(dialog().props("open")).toBe(true);
+ expect(dialog().props("views")).toEqual([tracesView]);
+ });
+
+ it("opens the saved-views dialog from the pinned dropdown's Manage item", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ await toolbarGroup().find('[data-test="traces-saved-view-manage"]').trigger("click");
+
+ expect(dialog().props("open")).toBe(true);
+ });
+
+ it("deletes from the dialog through the confirmation flow while unpinned", async () => {
+ togglePin("savedViews");
+ wrapper = mountSearchBar();
+ await flushPromises();
+ expect(toolbarGroup().exists()).toBe(false);
+
+ dialog().vm.$emit("delete", tracesView);
+ await flushPromises();
+
+ expect(mockConfirm).toHaveBeenCalledTimes(1);
+ expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1");
+ });
+
+ it("applies and updates views from the dialog", async () => {
+ mockGetViewDetail.mockResolvedValue({ data: { ...tracesView, data: expectedData() } });
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ dialog().vm.$emit("apply", tracesView);
+ await flushPromises();
+ expect(wrapper.emitted("apply-saved-view")?.[0]).toEqual([expectedData()]);
+
+ dialog().vm.$emit("update", tracesView);
+ await flushPromises();
+ expect(mockSavedViewsPut).toHaveBeenCalledWith(expect.any(String), "t1", {
+ view_name: "checkout errors",
+ data: expectedData(),
+ });
+ });
+
+ it("refetches the saved views each time the dialog opens", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+ const callsBefore = mockSavedViewsGet.mock.calls.length;
+ mockSavedViewsGet.mockResolvedValue({
+ data: { views: [tracesView, tracesView2, logsView] },
+ });
+
+ await wrapper
+ .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]')
+ .trigger("click");
+ await flushPromises();
+
+ expect(mockSavedViewsGet.mock.calls.length).toBe(callsBefore + 1);
+ expect(dialog().props("open")).toBe(true);
+ expect(dialog().props("views")).toEqual([tracesView, tracesView2]);
+
+ dialog().vm.$emit("update:open", false);
+ await flushPromises();
+ expect(dialog().props("open")).toBe(false);
+ await wrapper
+ .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]')
+ .trigger("click");
+ await flushPromises();
+
+ expect(mockSavedViewsGet.mock.calls.length).toBe(callsBefore + 2);
+ expect(dialog().props("open")).toBe(true);
+ });
+
+ it("prunes favourites deleted elsewhere once the list loads, freeing the cap", async () => {
+ const favourites: Record = {
+ l1: { ...logsView, org_id: "default" },
+ };
+ for (let i = 0; i < 9; i++) {
+ favourites[`gone${i}`] = { ...tracesView, view_id: `gone${i}` };
+ }
+ favourites.t1 = tracesView;
+ localStorage.setItem("savedViews", JSON.stringify(favourites));
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(dialog().props("favoriteIds")).toEqual(["t1"]);
+ expect(Object.keys(JSON.parse(localStorage.getItem("savedViews") || "{}"))).toEqual([
+ "l1",
+ "t1",
+ ]);
+
+ dialog().vm.$emit("toggle-favorite", tracesView2, false);
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toEqual(["t1", "t2"]);
+ });
+
+ it("keeps every favourite when the list fails to load", async () => {
+ mockSavedViewsGet.mockRejectedValue(new Error("boom"));
+ const gone = { ...tracesView, view_id: "gone" };
+ localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView, gone }));
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(dialog().props("loading")).toBe(false);
+ expect(dialog().props("views")).toEqual([]);
+ expect(dialog().props("favoriteIds")).toEqual(["t1", "gone"]);
+ expect(JSON.parse(localStorage.getItem("savedViews") || "{}")).toEqual({
+ t1: tracesView,
+ gone,
+ });
+ });
+
+ it("reports a successful delete even when localStorage rejects the write", async () => {
+ localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView }));
+ wrapper = mountSearchBar();
+ await flushPromises();
+ const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new DOMException("quota", "QuotaExceededError");
+ });
+
+ dialog().vm.$emit("delete", tracesView);
+ await flushPromises();
+ setItem.mockRestore();
+
+ expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1");
+ expect(dialog().props("favoriteIds")).toEqual([]);
+ expect(toastMock).toHaveBeenCalledWith({
+ message: "search.viewDeletedSuccessfully",
+ variant: "success",
+ });
+ });
+
+ it("passes the traces data-test prefix, views and favourites to the dialog", async () => {
+ localStorage.setItem(
+ "savedViews",
+ JSON.stringify({ t1: tracesView, l1: { ...logsView, org_id: "default" } }),
+ );
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(dialog().props("dataTestPrefix")).toBe("traces-saved-views-dialog");
+ expect(dialog().props("loading")).toBe(false);
+ expect(dialog().props("favoriteIds")).toEqual(["t1"]);
+ expect(dialog().props("favoriteViews")).toEqual([tracesView]);
+ });
+
+ it("update from the dialog closes it and asks for confirmation first", async () => {
+ mockConfirm.mockResolvedValue(false);
+ wrapper = mountSearchBar();
+ await flushPromises();
+ await wrapper
+ .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]')
+ .trigger("click");
+ expect(dialog().props("open")).toBe(true);
+
+ dialog().vm.$emit("update", tracesView);
+ await flushPromises();
+
+ expect(dialog().props("open")).toBe(false);
+ expect(mockConfirm).toHaveBeenCalledWith({
+ title: "search.updateSavedView",
+ message: "search.updateSavedViewConfirm",
+ });
+ expect(mockSavedViewsPut).not.toHaveBeenCalled();
+ });
+
+ it("delete from the dialog closes it, confirms, and drops the favourite", async () => {
+ localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView }));
+ wrapper = mountSearchBar();
+ await flushPromises();
+ await wrapper
+ .find('[data-test="traces-search-bar-menu-list-saved-views-btn"]')
+ .trigger("click");
+
+ dialog().vm.$emit("delete", tracesView);
+ await flushPromises();
+
+ expect(dialog().props("open")).toBe(false);
+ expect(mockConfirm).toHaveBeenCalledWith({
+ title: "search.deleteSavedView",
+ message: "search.deleteSavedViewConfirm",
+ });
+ expect(mockSavedViewsDelete).toHaveBeenCalledWith(expect.any(String), "t1");
+ expect(dialog().props("favoriteIds")).toEqual([]);
+ expect(JSON.parse(localStorage.getItem("savedViews") || "{}")).toEqual({});
+ });
+
+ it("delete from the dialog does nothing when not confirmed", async () => {
+ mockConfirm.mockResolvedValue(false);
+ localStorage.setItem("savedViews", JSON.stringify({ t1: tracesView }));
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ dialog().vm.$emit("delete", tracesView);
+ await flushPromises();
+
+ expect(mockSavedViewsDelete).not.toHaveBeenCalled();
+ expect(dialog().props("favoriteIds")).toEqual(["t1"]);
+ });
+
+ it("toggles a favourite from the dialog", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ dialog().vm.$emit("toggle-favorite", tracesView, false);
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toEqual(["t1"]);
+
+ dialog().vm.$emit("toggle-favorite", tracesView, true);
+ await flushPromises();
+ expect(dialog().props("favoriteIds")).toEqual([]);
+ });
+
+ it("pin click toggles the toolbar group without closing the menu", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+ expect(toolbarGroup().exists()).toBe(true);
+
+ const bubbled = vi.fn();
+ moreGroup().element.addEventListener("click", bubbled);
+ await moreGroup()
+ .find('[data-test="traces-search-bar-menu-pin-saved-views-btn"]')
+ .trigger("click");
+
+ expect(isPinned("savedViews")).toBe(false);
+ expect(toolbarGroup().exists()).toBe(false);
+ expect(bubbled).not.toHaveBeenCalled();
+
+ await moreGroup()
+ .find('[data-test="traces-search-bar-menu-pin-saved-views-btn"]')
+ .trigger("click");
+ expect(toolbarGroup().exists()).toBe(true);
+ });
+
+ it("opens the save dialog from Create saved view", async () => {
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ await wrapper
+ .find('[data-test="traces-search-bar-menu-create-saved-view-btn"]')
+ .trigger("click");
+ expect((wrapper.vm as any).saveViewDialogOpen).toBe(true);
+ });
+
+ it.each(["service-graph", "services-catalog"])(
+ "hides the group but keeps More on the %s tab",
+ async (mode) => {
+ searchObjInstance.meta.searchMode = mode as any;
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(wrapper.find('[data-test="traces-search-bar-more-menu-btn"]').exists()).toBe(true);
+ expect(moreGroup().exists()).toBe(false);
+ },
+ );
+
+ it("keeps the toolbar group off when unpinned", async () => {
+ togglePin("savedViews");
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(toolbarGroup().exists()).toBe(false);
+ expect(moreGroup().exists()).toBe(true);
+ });
+
+ it("shows the pinned group below lg without a width budget", async () => {
+ widthSpy.mockRestore();
+ widthSpy = mockToolbarWidth(0);
+ breakpointState.lgUp = false;
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(toolbarGroup().exists()).toBe(true);
+ });
+
+ it("shrinks the toggle labels before the pinned group falls back", async () => {
+ widthSpy.mockRestore();
+ widthSpy = mockToolbarWidth(800);
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(toolbarGroup().exists()).toBe(true);
+ expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).not.toContain(
+ "traces.spansTab",
+ );
+
+ togglePin("savedViews");
+ await flushPromises();
+ expect(wrapper.find('[data-test="traces-search-mode-spans-btn"]').text()).toContain(
+ "traces.spansTab",
+ );
+ });
+
+ it("falls back to More at desktop width when the toolbar has no room", async () => {
+ widthSpy.mockRestore();
+ widthSpy = mockToolbarWidth(360);
+ wrapper = mountSearchBar();
+ await flushPromises();
+
+ expect(toolbarGroup().exists()).toBe(false);
+ expect(moreGroup().exists()).toBe(true);
+ });
});
});
});
diff --git a/web/src/plugins/traces/SearchBar.vue b/web/src/plugins/traces/SearchBar.vue
index 46fa66b745..8a110b2edc 100644
--- a/web/src/plugins/traces/SearchBar.vue
+++ b/web/src/plugins/traces/SearchBar.vue
@@ -21,86 +21,165 @@ along with this program. If not, see .
>
-
-
+
-
+
-
- {{ t("traces.spansTab") }}
-
-
-
- {{ t("traces.tracesTab") }}
-
-
-
- {{ t("traces.serviceGraphTab") }}
-
-
-
- {{ t("traces.servicesCatalog.tabLabel") }}
-
-
+
+
+ {{ t("traces.spansTab") }}
+
+
+
+ {{ t("traces.tracesTab") }}
+
+
+
+ {{ t("traces.serviceGraphTab") }}
+
+
+
+ {{ t("traces.servicesCatalog.tabLabel") }}
+
+
-
-
-
-
- {{ t("traces.drillDown") }}
-
-
-
-
-
-
-
+
- {{ t("common.reset") }}
+ {{ t("traces.drillDown") }}
+
-
+
+
+
+
+
+
+
+
+ {{ t("common.reset") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("search.createSavedView") }}
+
+
+ {{ t("search.manageSavedViews") }}
+
+
+
+
+
+
+
+
+
@@ -109,17 +188,14 @@ along with this program. If not, see .
variant="outline"
size="xs"
icon-left="more-horiz"
- class="max-lg:ms-auto"
+ class="shrink-0 max-lg:ms-auto"
>
{{ t("search.menuMore") }}
@@ -132,16 +208,77 @@ along with this program. If not, see .
{{ t("traces.redMetrics") }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t("search.listSavedViews") }}
+
+
+
+
+
+ {{ t("search.createSavedView") }}
+
+
+
+
+
.
>
-
-
-
-
-
-
-
-
-
-
-
-
- {{ view.view_name }}
-
-
-
-
-
-
-
- {{ t("search.savedViewsNotFound") }}
-
-
-
-
- {{ t("search.createSavedView") }}
-
-
-
-
-
-
-
-
.
data-test="traces-saved-view-name-input"
/>
+
@@ -512,11 +588,17 @@ import OSeparator from "@/lib/core/Separator/OSeparator.vue";
import useTraces from "@/composables/useTraces";
import { useSqlEditorDiagnostics } from "@/composables/useSqlEditorDiagnostics";
import SyntaxGuide from "./SyntaxGuide.vue";
+import TracesSavedViewsMenuItems from "./TracesSavedViewsMenuItems.vue";
+import SavedViewsListDialog from "@/components/savedViews/SavedViewsListDialog.vue";
+import { sortSavedViews } from "@/components/savedViews/savedViewsSort";
+import { useFavoriteSavedViews } from "@/composables/useFavoriteSavedViews";
import { debounce } from "lodash-es";
import analytics from "@/services/product_analytics";
import config from "@/aws-exports";
import { useToolbarResponsive } from "@/composables/useToolbarResponsive";
+import { useToolbarPins } from "@/composables/useToolbarPins";
+import useBreakpoint from "@/composables/useBreakpoint";
import useSqlSuggestions from "@/composables/useSuggestions";
import useStreams from "@/composables/useStreams";
import {
@@ -561,6 +643,8 @@ export default defineComponent({
OTooltip,
CodeQueryEditor: defineAsyncComponent(() => import("@/components/CodeQueryEditor.vue")),
SyntaxGuide,
+ TracesSavedViewsMenuItems,
+ SavedViewsListDialog,
},
emits: [
"searchdata",
@@ -996,6 +1080,22 @@ export default defineComponent({
const tracesSavedViews = computed(() =>
(savedViewsList.data.value ?? []).filter((view) => view.view_type === "traces"),
);
+ const savedViewsLoading = computed(() => savedViewsList.isLoading.value);
+ const { favoriteIds, favoriteViews, toggleFavorite, removeFavorite, pruneFavorites } =
+ useFavoriteSavedViews("traces");
+ // Only a successful fetch is the complete list; pruning on error would drop every favourite.
+ watch(
+ () => savedViewsList.data.value,
+ (views) => {
+ if (savedViewsList.isSuccess.value && views) {
+ pruneFavorites(views.map((view) => view.view_id));
+ }
+ },
+ { immediate: true },
+ );
+ const sortedTracesSavedViews = computed(() =>
+ sortSavedViews(tracesSavedViews.value, favoriteIds.value),
+ );
const createSavedView = useMutation(() => createSavedViewMutation(orgId.value));
const updateSavedView = useMutation(() => updateSavedViewMutation(orgId.value));
const deleteSavedView = useMutation(() => deleteSavedViewMutation(orgId.value));
@@ -1030,6 +1130,12 @@ export default defineComponent({
saveViewDialogOpen.value = true;
};
+ const savedViewsDialogOpen = ref(false);
+ const openSavedViewsDialog = () => {
+ savedViewsList.refetch();
+ savedViewsDialogOpen.value = true;
+ };
+
const saveAsNewView = async () => {
const viewName = newViewName.value.trim();
if (!viewName) {
@@ -1067,7 +1173,23 @@ export default defineComponent({
}
};
+ const savedViewsDropdownOpen = ref(false);
+ const quickUpdateSavedView = (view) => {
+ savedViewsDropdownOpen.value = false;
+ return updateSavedViewFromCurrent(view);
+ };
+
+ const confirmUpdateSavedView = async (view) => {
+ savedViewsDialogOpen.value = false;
+ const confirmed = await confirm({
+ title: t("search.updateSavedView"),
+ message: t("search.updateSavedViewConfirm"),
+ });
+ if (confirmed) await updateSavedViewFromCurrent(view);
+ };
+
const deleteTracesSavedView = async (view) => {
+ savedViewsDialogOpen.value = false;
const confirmed = await confirm({
title: t("search.deleteSavedView"),
message: t("search.deleteSavedViewConfirm"),
@@ -1075,6 +1197,7 @@ export default defineComponent({
if (!confirmed) return;
try {
await deleteSavedView.mutateAsync(view.view_id);
+ removeFavorite(view.view_id);
toast({ message: t("search.viewDeletedSuccessfully"), variant: "success" });
} catch (err) {
toast({
@@ -1100,18 +1223,44 @@ export default defineComponent({
// Responsive toolbar — shared composable tracks available left-section width
const { toolbarLeftRef, toolbarRightRef, availableLeftWidth } = useToolbarResponsive();
+ const { lgUp } = useBreakpoint();
+ const { isPinned, togglePin } = useToolbarPins("traces");
- // Traces-specific breakpoints (actual content widths + 60px buffer to fire before clipping):
- // Toggle items with text: ~682px total → hide at 750 (682+68 buffer)
- // After toggle icon-only (~459px) + reset text: hide reset text at 540
- const shouldHideToggleText = computed(() => availableLeftWidth.value < 750);
- const shouldHideResetText = computed(() => availableLeftWidth.value < 540);
+ const isSearchMode = computed(
+ () =>
+ searchObj.meta.searchMode !== "service-graph" &&
+ searchObj.meta.searchMode !== "services-catalog",
+ );
+
+ // Approximate rendered widths of the Traces left-section items, used only to decide whether the pinned group fits.
+ const TOOLBAR_ITEM_GAP = 6;
+ const METRICS_PINNED_WIDTH = 72 + TOOLBAR_ITEM_GAP;
+ const SAVED_VIEWS_GROUP_WIDTH = 87 + TOOLBAR_ITEM_GAP;
+ const TOGGLE_WIDTH = { enterprise: { text: 422, icon: 150 }, oss: { text: 298, icon: 114 } };
+ const DRILL_DOWN_WIDTH = { text: 97, icon: 36 };
+ const RESET_WIDTH = { text: 72, icon: 36 };
+ const MORE_WIDTH = 70;
+
+ const metricsPinned = computed(() => isSearchMode.value && isPinned("histogram"));
+ const savedViewsPinned = computed(() => isSearchMode.value && isPinned("savedViews"));
+ const pinnedGroupWidth = computed(
+ () =>
+ (metricsPinned.value ? METRICS_PINNED_WIDTH : 0) +
+ (savedViewsPinned.value ? SAVED_VIEWS_GROUP_WIDTH : 0),
+ );
+
+ // Traces-specific breakpoints, raised by the pinned group so labels shrink before it falls back into More.
+ const shouldHideToggleText = computed(
+ () => availableLeftWidth.value < 750 + pinnedGroupWidth.value,
+ );
+ const shouldHideResetText = computed(
+ () => availableLeftWidth.value < 540 + pinnedGroupWidth.value,
+ );
// Only while Index renders SearchResult with its header, since the click is routed to it.
const showDrillDown = computed(
() =>
- searchObj.meta.searchMode !== "service-graph" &&
- searchObj.meta.searchMode !== "services-catalog" &&
+ isSearchMode.value &&
!searchObj.loadingStream &&
!searchObj.loading &&
searchObj.data.stream.streamLists.length > 0 &&
@@ -1120,6 +1269,26 @@ export default defineComponent({
!!searchObj.searchApplied,
);
+ const leftBaseWidth = computed(() => {
+ const textKey = shouldHideToggleText.value ? "icon" : "text";
+ const toggle = config.isEnterprise == "true" ? TOGGLE_WIDTH.enterprise : TOGGLE_WIDTH.oss;
+ const widths = [toggle[textKey], MORE_WIDTH];
+ if (showDrillDown.value) widths.push(DRILL_DOWN_WIDTH[textKey]);
+ if (isSearchMode.value) widths.push(RESET_WIDTH[shouldHideResetText.value ? "icon" : "text"]);
+ return widths.reduce((sum, w) => sum + w, 0) + (widths.length - 1) * TOOLBAR_ITEM_GAP;
+ });
+
+ // Below lg the bar wraps and availableLeftWidth reads ~0, so pinned items need no budget there.
+ const pinnedFit = computed(() => {
+ if (!lgUp.value) return { metrics: metricsPinned.value, savedViews: savedViewsPinned.value };
+ let budget = availableLeftWidth.value - leftBaseWidth.value;
+ const metrics = metricsPinned.value && budget >= METRICS_PINNED_WIDTH;
+ if (metrics) budget -= METRICS_PINNED_WIDTH;
+ return { metrics, savedViews: savedViewsPinned.value && budget >= SAVED_VIEWS_GROUP_WIDTH };
+ });
+ const showPinnedMetrics = computed(() => pinnedFit.value.metrics);
+ const showPinnedSavedViews = computed(() => pinnedFit.value.savedViews);
+
return {
t,
router,
@@ -1156,15 +1325,30 @@ export default defineComponent({
shouldHideToggleText,
shouldHideResetText,
showDrillDown,
+ isSearchMode,
+ isPinned,
+ togglePin,
+ showPinnedMetrics,
+ showPinnedSavedViews,
tracesSavedViews,
isSavingView,
saveViewDialogOpen,
newViewName,
openSaveViewDialog,
+ savedViewsDialogOpen,
+ openSavedViewsDialog,
saveAsNewView,
updateSavedViewFromCurrent,
deleteTracesSavedView,
applySavedView,
+ savedViewsLoading,
+ favoriteIds,
+ favoriteViews,
+ toggleFavorite,
+ sortedTracesSavedViews,
+ savedViewsDropdownOpen,
+ quickUpdateSavedView,
+ confirmUpdateSavedView,
};
},
computed: {
diff --git a/web/src/plugins/traces/TracesSavedViewsMenuItems.vue b/web/src/plugins/traces/TracesSavedViewsMenuItems.vue
new file mode 100644
index 0000000000..0a63527d1c
--- /dev/null
+++ b/web/src/plugins/traces/TracesSavedViewsMenuItems.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+ {{ t("confirmDialog.loading") }}
+
+
+
+
+
+
+ {{ view.view_name }}
+
+
+
+
+
+
+ {{ t("search.savedViewsNotFound") }}
+
+
diff --git a/web/src/utils/storage.spec.ts b/web/src/utils/storage.spec.ts
index 978206b584..cfc47e8faa 100644
--- a/web/src/utils/storage.spec.ts
+++ b/web/src/utils/storage.spec.ts
@@ -35,7 +35,6 @@ import {
useLocalLogFilterField,
useLocalTraceFilterField,
useLocalInterestingFields,
- useLocalSavedView,
useLocalUserInfo,
useLocalTimezone,
useLocalWrapContent,
@@ -243,20 +242,6 @@ describe("useLocalInterestingFields", () => {
});
});
-// ---------------------------------------------------------------------------
-// useLocalSavedView
-// ---------------------------------------------------------------------------
-
-describe("useLocalSavedView", () => {
- it("reads from localStorage with key 'savedViews'", () => {
- localStorageMock.getItem.mockReturnValue(null);
-
- useLocalSavedView("val");
-
- expect(localStorageMock.getItem).toHaveBeenCalledWith("savedViews");
- });
-});
-
// ---------------------------------------------------------------------------
// useLocalUserInfo — returns .value (primitive), not the ref
// ---------------------------------------------------------------------------
diff --git a/web/src/utils/storage.ts b/web/src/utils/storage.ts
index b5f06953fc..68578b4962 100644
--- a/web/src/utils/storage.ts
+++ b/web/src/utils/storage.ts
@@ -119,10 +119,6 @@ export const useLocalInterestingFields = (val = "", isDelete = false) => {
return useLocalStorage("interestingFields", val, isDelete, true);
};
-export const useLocalSavedView = (val = "", isDelete = false) => {
- return useLocalStorage("savedViews", val, isDelete, true);
-};
-
export const useLocalUserInfo = (val = "", isDelete = false) => {
const userInfo: any = useLocalStorage("userInfo", val, isDelete);
return userInfo.value;