@@ -86,7 +89,12 @@ const stubs = {
emits: ["action"],
template: `
{{ description }}
`,
},
- OSearchInput: { name: "OSearchInput", template: "
" },
+ OSearchInput: {
+ name: "OSearchInput",
+ props: ["modelValue"],
+ emits: ["update:modelValue"],
+ template: "
",
+ },
// `props` declared deliberately: a stub that does not declare what it is
// passed drops it silently, and an assertion about a tooltip then passes
// with no tooltip mounted.
@@ -129,6 +137,7 @@ function onCallCell(wrapper: any, _row?: unknown) {
describe("OnCallTeams", () => {
beforeEach(() => {
__resetOnCallRoutingConfig();
+ resetListViewState();
vi.clearAllMocks();
mockConfirm.mockResolvedValue(true);
for (const key of Object.keys(routeQuery)) delete routeQuery[key];
@@ -276,6 +285,55 @@ describe("OnCallTeams", () => {
expect(service.whoIsOnCall).toHaveBeenCalledTimes(2);
});
+ describe("list view state", () => {
+ const seed = () =>
+ useListViewState("oncall-teams", {
+ search: "",
+ currentPage: 1,
+ pageSize: 20,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+ });
+
+ it("restores search, page, page size and sort on mount", async () => {
+ service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
+ Object.assign(seed(), {
+ search: "plat",
+ currentPage: 2,
+ pageSize: 50,
+ sortBy: "name",
+ sortOrder: "desc",
+ });
+ const wrapper = render();
+ await flushPromises();
+
+ expect(wrapper.findComponent({ name: "OSearchInput" }).props("modelValue")).toBe("plat");
+ const table = wrapper.findComponent({ name: "OTable" });
+ expect(table.props("currentPage")).toBe(2);
+ expect(table.props("pageSize")).toBe(50);
+ expect(table.props("sortBy")).toBe("name");
+ expect(table.props("sortOrder")).toBe("desc");
+ });
+
+ it("saves search, page and sort changes", async () => {
+ service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
+ const wrapper = render();
+ await flushPromises();
+
+ wrapper.findComponent({ name: "OSearchInput" }).vm.$emit("update:modelValue", "pay");
+ await flushPromises();
+ const table = wrapper.findComponent({ name: "OTable" });
+ table.vm.$emit("update:currentPage", 3);
+ table.vm.$emit("update:sortBy", "name");
+ await flushPromises();
+
+ const saved = seed();
+ expect(saved.search).toBe("pay");
+ expect(saved.currentPage).toBe(3);
+ expect(saved.sortBy).toBe("name");
+ });
+ });
+
it("opens a team on row click", async () => {
service.listTeams.mockResolvedValue({ data: [team("team_1", "Platform")] } as any);
const wrapper = render();
diff --git a/web/src/views/OnCall/OnCallTeams.vue b/web/src/views/OnCall/OnCallTeams.vue
index 4551b2fdd0..bf2ad26f27 100644
--- a/web/src/views/OnCall/OnCallTeams.vue
+++ b/web/src/views/OnCall/OnCallTeams.vue
@@ -35,6 +35,7 @@ along with this program. If not, see
.
.
:show-global-filter="false"
:enable-column-resize="true"
data-test="oncall-teams-table"
+ :page-size="pageSize"
+ :current-page="currentPage"
+ :sort-by="sortBy"
+ :sort-order="sortOrder"
@row-click="openTeam"
+ @update:current-page="currentPage = $event"
+ @update:page-size="pageSize = $event"
+ @update:sort-by="sortBy = $event"
+ @update:sort-order="sortOrder = $event"
>
@@ -298,6 +307,7 @@ import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import OUserCell from "@/lib/core/Table/cells/OUserCell.vue";
import { COL, type OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { useConfirmDialog } from "@/composables/useConfirmDialog";
+import { useListViewState } from "@/composables/useListViewState";
import { useOnCallPermissions } from "@/composables/useOnCallPermissions";
import { useOnCallRoutingConfig } from "@/composables/useOnCallRoutingConfig";
import { queryClient } from "@/composables/query/queryClient";
@@ -328,7 +338,19 @@ const loading = ref(false);
const lastUpdatedAt = ref
(null);
const loadError = ref(null);
const notAvailable = ref(false);
-const search = ref("");
+const savedView = useListViewState("oncall-teams", {
+ search: "",
+ currentPage: 1,
+ pageSize: 20,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+});
+const search = ref(savedView.search);
+const currentPage = ref(savedView.currentPage);
+const pageSize = ref(savedView.pageSize);
+const sortBy = ref(savedView.sortBy);
+const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
+const oTableRef = ref(null);
const formOpen = ref(false);
const editingTeam = ref(null);
const teamToDelete = ref(null);
@@ -458,6 +480,26 @@ const filteredTeams = computed(() => {
);
});
+watch(search, () => {
+ currentPage.value = 1;
+});
+watch([search, currentPage, pageSize, sortBy, sortOrder], () => {
+ Object.assign(savedView, {
+ search: search.value,
+ currentPage: currentPage.value,
+ pageSize: pageSize.value,
+ sortBy: sortBy.value,
+ sortOrder: sortOrder.value,
+ });
+});
+// The first load lands after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
+let pageRestored = false;
+watch(loading, (isLoading) => {
+ if (isLoading || pageRestored) return;
+ pageRestored = true;
+ setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
+});
+
async function fetchTeams(force = false) {
loading.value = true;
try {
diff --git a/web/src/views/RUM/SourceMaps.spec.ts b/web/src/views/RUM/SourceMaps.spec.ts
index 2f4e60b185..ae73963396 100644
--- a/web/src/views/RUM/SourceMaps.spec.ts
+++ b/web/src/views/RUM/SourceMaps.spec.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { resetListViewState, useListViewState } from "@/composables/useListViewState";
import { mount, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import { createStore } from "vuex";
@@ -155,6 +156,7 @@ describe("SourceMaps.vue", () => {
let wrapper: any;
beforeEach(() => {
+ resetListViewState();
store = createMockStore();
router = createMockRouter();
i18n = createMockI18n();
@@ -660,4 +662,34 @@ describe("SourceMaps.vue", () => {
expect((wrapper.vm as any).formatTimestamp(null)).toBe("-");
});
});
+
+ describe("View state restore", () => {
+ it("restores filters, page, size and sort and fetches with the filters", async () => {
+ useListViewState("rumSourceMaps", {
+ filters: { service: "web" },
+ pageSize: 50,
+ currentPage: 2,
+ sortBy: "version",
+ sortOrder: "desc",
+ });
+ wrapper = await mountComponent();
+ const vm = wrapper.vm as any;
+ expect(vm.filters).toEqual({ service: "web" });
+ expect(vm.selectedPerPage).toBe(50);
+ expect(vm.sortBy).toBe("version");
+ expect(vm.sortOrder).toBe("desc");
+ expect(listSourceMapsMock).toHaveBeenCalledWith(expect.anything(), { service: "web" });
+ });
+
+ it("saves changes to the view state", async () => {
+ wrapper = await mountComponent();
+ const vm = wrapper.vm as any;
+ vm.filters = { version: "1.0" };
+ vm.selectedPerPage = 100;
+ await nextTick();
+ const saved: any = useListViewState("rumSourceMaps", {});
+ expect(saved.filters).toEqual({ version: "1.0" });
+ expect(saved.pageSize).toBe(100);
+ });
+ });
});
diff --git a/web/src/views/RUM/SourceMaps.vue b/web/src/views/RUM/SourceMaps.vue
index 444cd0849b..88af93461b 100644
--- a/web/src/views/RUM/SourceMaps.vue
+++ b/web/src/views/RUM/SourceMaps.vue
@@ -97,6 +97,7 @@ along with this program. If not, see .
.
pagination="client"
:page-size="selectedPerPage"
:page-size-options="perPageOptionsList"
+ :current-page="currentPage"
+ @update:current-page="currentPage = $event"
+ @update:page-size="selectedPerPage = $event"
+ :sort-by="sortBy"
+ :sort-order="sortOrder"
+ @update:sort-by="sortBy = $event"
+ @update:sort-order="sortOrder = $event"
:show-global-filter="false"
expansion="single"
expand-on-row-click
@@ -196,7 +204,8 @@ along with this program. If not, see
.
// would silently drop it from the cache and bring back the refetch-on-return.
defineOptions({ name: "SourceMaps" });
-import { ref, onMounted, computed } from "vue";
+import { ref, onMounted, computed, watch } from "vue";
+import { useListViewState } from "@/composables/useListViewState";
import { raw, useI18nTyped } from "@/types/i18n";
import { useStore } from "vuex";
import { useRouter } from "vue-router";
@@ -239,11 +248,15 @@ const filteredServiceOptions = ref
([]);
const filteredEnvironmentOptions = ref([]);
// Filters
-const filters = ref<{
- version?: string;
- service?: string;
- environment?: string;
-}>({});
+type SourceMapFilters = { version?: string; service?: string; environment?: string };
+const viewState = useListViewState("rumSourceMaps", {
+ filters: {} as SourceMapFilters,
+ pageSize: 20,
+ currentPage: 1,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+});
+const filters = ref({ ...viewState.filters });
// Fetch filter values from API
const fetchFilterValues = async () => {
@@ -338,7 +351,22 @@ const columns = computed(() => [
]);
// Pagination
-const selectedPerPage = ref(20);
+const selectedPerPage = ref(viewState.pageSize);
+const currentPage = ref(viewState.currentPage);
+const sortBy = ref(viewState.sortBy);
+const sortOrder = ref<"asc" | "desc">(viewState.sortOrder);
+const oTableRef = ref(null);
+watch([filters, selectedPerPage, currentPage, sortBy, sortOrder], () => {
+ Object.assign(viewState, {
+ filters: { ...filters.value },
+ pageSize: selectedPerPage.value,
+ currentPage: currentPage.value,
+ sortBy: sortBy.value,
+ sortOrder: sortOrder.value,
+ });
+});
+watch(filters, () => (currentPage.value = 1), { deep: true });
+let pageRestored = false;
const perPageOptionsList = [20, 50, 100, 250];
@@ -370,6 +398,11 @@ const fetchSourceMaps = async () => {
groupedSourceMaps.value = [];
} finally {
isLoading.value = false;
+ if (!pageRestored) {
+ pageRestored = true;
+ // Macrotask so the restored page wins over the table's own reset on data change.
+ setTimeout(() => oTableRef.value?.restorePage?.(currentPage.value), 0);
+ }
}
};
diff --git a/web/src/views/SyntheticMonitoring.spec.ts b/web/src/views/SyntheticMonitoring.spec.ts
index 36d9a69da8..3583bc50e7 100644
--- a/web/src/views/SyntheticMonitoring.spec.ts
+++ b/web/src/views/SyntheticMonitoring.spec.ts
@@ -21,6 +21,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, VueWrapper, flushPromises } from "@vue/test-utils";
import { nextTick } from "vue";
import store from "@/test/unit/helpers/store";
+import { resetListViewState, useListViewState } from "@/composables/useListViewState";
const $t = (key: string) => key;
@@ -154,7 +155,12 @@ const baseStubs = {
"bulkActionLoading",
"emptyMessage",
"hasFilters",
+ "pageSize",
+ "currentPage",
+ "sortBy",
+ "sortOrder",
],
+ emits: ["update:currentPage", "update:pageSize", "update:sortBy", "update:sortOrder"],
},
CheckTypePicker: {
template: '
',
@@ -270,6 +276,7 @@ describe("SyntheticMonitoring", () => {
beforeEach(() => {
vi.clearAllMocks();
+ resetListViewState();
mockServiceList.mockResolvedValue({ data: { monitors: [] } });
// Both are process-global and mutated by individual tests, so they are
// restored here rather than left for the next test to inherit.
@@ -299,6 +306,75 @@ describe("SyntheticMonitoring", () => {
});
});
+ describe("list view state", () => {
+ const seed = () =>
+ useListViewState("synthetics", {
+ folder: "",
+ activeTab: "all",
+ statusFilter: "all",
+ typeFilter: "all",
+ locationFilter: "all",
+ search: "",
+ currentPage: 1,
+ pageSize: 20,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+ });
+
+ it("restores search, page, page size and sort from the saved view", () => {
+ Object.assign(seed(), {
+ search: "checkout",
+ currentPage: 3,
+ pageSize: 50,
+ sortBy: "name",
+ sortOrder: "desc",
+ });
+ wrapper = mountPage();
+ const mt = wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]');
+ expect(
+ (
+ wrapper.find('[data-test="synthetic-monitoring-search-input"]')
+ .element as HTMLInputElement
+ ).value,
+ ).toBe("checkout");
+ expect(mt.props("currentPage")).toBe(3);
+ expect(mt.props("pageSize")).toBe(50);
+ expect(mt.props("sortBy")).toBe("name");
+ expect(mt.props("sortOrder")).toBe("desc");
+ });
+
+ it("restores the type tab and drops the stored page when the folder differs", () => {
+ Object.assign(seed(), {
+ activeTab: "browser",
+ typeFilter: "BROWSER",
+ currentPage: 4,
+ folder: "other",
+ });
+ mockRoute.query.folder = "default";
+ wrapper = mountPage();
+ const mt = wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]');
+ expect(mt.props("mode")).toBe("browser");
+ expect(mt.props("currentPage")).toBe(1);
+ });
+
+ it("saves search and table changes back to the view state", async () => {
+ wrapper = mountPage();
+ await wrapper.find('[data-test="synthetic-monitoring-search-input"]').setValue("api");
+ const mt = wrapper.findComponent('[data-test="synthetic-monitoring-monitors-table"]');
+ mt.vm.$emit("update:currentPage", 2);
+ mt.vm.$emit("update:sortBy", "status");
+ mt.vm.$emit("update:sortOrder", "desc");
+ mt.vm.$emit("update:pageSize", 25);
+ await nextTick();
+ const saved = seed();
+ expect(saved.search).toBe("api");
+ expect(saved.currentPage).toBe(2);
+ expect(saved.sortBy).toBe("status");
+ expect(saved.sortOrder).toBe("desc");
+ expect(saved.pageSize).toBe(25);
+ });
+ });
+
describe("service calls on mount", () => {
it("should call listByFolderId when initialised", async () => {
wrapper = mountPage();
diff --git a/web/src/views/SyntheticMonitoring.vue b/web/src/views/SyntheticMonitoring.vue
index e909dcae88..15165f7f1f 100644
--- a/web/src/views/SyntheticMonitoring.vue
+++ b/web/src/views/SyntheticMonitoring.vue
@@ -120,7 +120,16 @@ along with this program. If not, see .
>
(activeTab.value === "browser" ? "browser" : "all"));
-const statusFilter = ref("all");
-const typeFilter = ref("all");
-const locationFilter = ref("all");
-const search = ref("");
+const statusFilter = ref(savedView.statusFilter);
+const typeFilter = ref(savedView.typeFilter);
+const locationFilter = ref(savedView.locationFilter);
+const search = ref(savedView.search);
+const currentPage = ref(savedView.currentPage);
+const pageSize = ref(savedView.pageSize);
+const sortBy = ref(savedView.sortBy);
+const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
+const monitorTableRef = ref | null>(null);
+
+watch([activeTab, statusFilter, typeFilter, locationFilter, search], () => {
+ currentPage.value = 1;
+});
+watch(
+ [
+ activeTab,
+ statusFilter,
+ typeFilter,
+ locationFilter,
+ search,
+ currentPage,
+ pageSize,
+ sortBy,
+ sortOrder,
+ ],
+ () => {
+ Object.assign(savedView, {
+ folder: activeFolderId.value,
+ activeTab: activeTab.value,
+ statusFilter: statusFilter.value,
+ typeFilter: typeFilter.value,
+ locationFilter: locationFilter.value,
+ search: search.value,
+ currentPage: currentPage.value,
+ pageSize: pageSize.value,
+ sortBy: sortBy.value,
+ sortOrder: sortOrder.value,
+ });
+ },
+);
+// The first data load makes TanStack reset to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
+let pageRestored = false;
+watch(loading, (isLoading) => {
+ if (isLoading || pageRestored) return;
+ pageRestored = true;
+ setTimeout(() => monitorTableRef.value?.restorePage?.(currentPage.value), 0);
+});
const showTypePicker = ref(false);
// ── Folder state ───────────────────────────────────────────────────────
@@ -796,6 +864,8 @@ const activeFolderName = computed(() => {
});
watch(activeFolderId, async (newFolderId) => {
+ currentPage.value = 1;
+ savedView.folder = newFolderId;
selectedMonitorIds.value = [];
duplicateFolder.value = newFolderId;
if (searchAcrossFolders.value) {
diff --git a/web/src/views/alerts/AlertDetail.vue b/web/src/views/alerts/AlertDetail.vue
index 2834f94722..488db31a30 100644
--- a/web/src/views/alerts/AlertDetail.vue
+++ b/web/src/views/alerts/AlertDetail.vue
@@ -454,7 +454,7 @@ const backTarget = computed(() => ({
to: {
name: "alertList",
query: {
- // Spread first so page/tab/search survive — folder still needs its own explicit fallback below since an absent query.folder must resolve to "default", not itself disappear.
+ // Spread first so the tab the list pushed survives — folder still needs its own explicit fallback below since an absent query.folder must resolve to "default", not itself disappear.
...route.query,
org_identifier: orgId.value,
folder: route.query.folder || "default",
@@ -674,6 +674,7 @@ const editAlert = () => {
query: {
org_identifier: orgId.value,
folder: route.query.folder || "default",
+ tab: route.query.tab,
},
});
return;
@@ -687,6 +688,7 @@ const editAlert = () => {
// navigated with; "default" is the folder every org is created with. The
// editor needs it to send the user back to the right folder on save.
folder: route.query.folder || "default",
+ tab: route.query.tab,
},
});
};
diff --git a/web/src/views/slos/SloList.vue b/web/src/views/slos/SloList.vue
index 3e79a803f8..2df9ba4af5 100644
--- a/web/src/views/slos/SloList.vue
+++ b/web/src/views/slos/SloList.vue
@@ -57,8 +57,13 @@
row-key="id"
:loading="loading"
:error="error"
- :page-size="25"
+ :page-size="pageSize"
:page-size-options="[25, 50, 100]"
+ :sort-by="sortBy"
+ :sort-order="sortOrder"
+ @update:sort-by="sortBy = $event"
+ @update:sort-order="sortOrder = $event"
+ @update:page-size="pageSize = $event"
:current-page="currentPage"
:show-global-filter="false"
table-id="slos-list"
@@ -510,6 +515,7 @@ import type { StatItem } from "@/lib/data/StatStrip/OStatStrip.types";
import type { SloListItem } from "@/ts/interfaces/slo";
import { toast } from "@/lib/feedback/Toast/useToast";
import useBreakpoint from "@/composables/useBreakpoint";
+import { useListViewState } from "@/composables/useListViewState";
import alertsService from "@/services/alerts";
import { sloDetailRoute } from "@/utils/alerts/sloAlertRouting";
import { slosToTerraform } from "@/utils/slos/sloTerraform";
@@ -567,9 +573,30 @@ const error = computed(() => {
if (!e) return null;
return e?.response?.data?.message || e?.message || t("slos.loadFailed");
});
-const search = ref("");
-const typeFilter = ref("all");
-const healthFilter = ref(null);
+const savedView = useListViewState("slos", {
+ search: "",
+ typeFilter: "all",
+ healthFilter: null as string | null,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+ pageSize: 25,
+});
+const search = ref(savedView.search);
+const typeFilter = ref(savedView.typeFilter);
+const healthFilter = ref(savedView.healthFilter);
+const sortBy = ref(savedView.sortBy);
+const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
+const pageSize = ref(savedView.pageSize);
+watch([search, typeFilter, healthFilter, sortBy, sortOrder, pageSize], () => {
+ Object.assign(savedView, {
+ search: search.value,
+ typeFilter: typeFilter.value,
+ healthFilter: healthFilter.value,
+ sortBy: sortBy.value,
+ sortOrder: sortOrder.value,
+ pageSize: pageSize.value,
+ });
+});
const deleteDialog = ref(false);
const pendingDelete = ref(null);
const selectedIds = ref([]);
diff --git a/web/src/views/slos/SloListPagination.spec.ts b/web/src/views/slos/SloListPagination.spec.ts
index c0eec29ef9..c434609665 100644
--- a/web/src/views/slos/SloListPagination.spec.ts
+++ b/web/src/views/slos/SloListPagination.spec.ts
@@ -36,6 +36,7 @@ import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
import sloService from "@/services/slos";
+import { resetListViewState, useListViewState } from "@/composables/useListViewState";
const node = document.createElement("div");
node.setAttribute("id", "app");
@@ -233,3 +234,66 @@ describe("SloList pagination persistence", () => {
expect(pushed.query.folder).toBe("team-a");
});
});
+
+describe("SloList view state persistence", () => {
+ let wrapper: any;
+ const defaults = {
+ search: "",
+ typeFilter: "all",
+ healthFilter: null as string | null,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+ pageSize: 25,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ resetListViewState();
+ (router as any).currentRoute.value.query = {};
+ vi.mocked(sloService.list).mockResolvedValue({ data: { list: [sloRow()] } } as any);
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ resetListViewState();
+ vi.restoreAllMocks();
+ });
+
+ it("seeds search, type, health and sort from the saved view", async () => {
+ Object.assign(useListViewState("slos", defaults), {
+ search: "checkout",
+ typeFilter: "metrics",
+ healthFilter: "healthy",
+ sortBy: "name",
+ sortOrder: "desc",
+ pageSize: 50,
+ });
+ wrapper = await mountList();
+
+ const vm = wrapper.vm as any;
+ expect(vm.search).toBe("checkout");
+ expect(vm.typeFilter).toBe("metrics");
+ expect(vm.healthFilter).toBe("healthy");
+ expect(vm.sortBy).toBe("name");
+ expect(vm.sortOrder).toBe("desc");
+ expect(vm.pageSize).toBe(50);
+ });
+
+ it("saves filter changes into the view state", async () => {
+ wrapper = await mountList();
+ const vm = wrapper.vm as any;
+
+ vm.search = "api";
+ vm.onStatSelect("breached");
+ vm.sortBy = "target";
+ vm.sortOrder = "desc";
+ await flushPromises();
+
+ expect(useListViewState("slos", defaults)).toMatchObject({
+ search: "api",
+ healthFilter: "breached",
+ sortBy: "target",
+ sortOrder: "desc",
+ });
+ });
+});
diff --git a/web/src/views/synthetics/MonitorRuns.spec.ts b/web/src/views/synthetics/MonitorRuns.spec.ts
index 37c9a67dd3..20a0feacfb 100644
--- a/web/src/views/synthetics/MonitorRuns.spec.ts
+++ b/web/src/views/synthetics/MonitorRuns.spec.ts
@@ -220,6 +220,7 @@ vi.mock("vue-router", () => ({
}));
import MonitorRuns from "./MonitorRuns.vue";
+import { resetListViewState, useListViewState } from "@/composables/useListViewState";
// ── Stubs for every child component ─────────────────────────────────────
const baseStubs = {
@@ -292,6 +293,7 @@ const baseStubs = {
"pagination",
"pageSize",
"pageSizeOptions",
+ "currentPage",
"sorting",
"sortBy",
"sortOrder",
@@ -380,12 +382,52 @@ describe("MonitorRuns", () => {
beforeEach(() => {
vi.clearAllMocks();
+ resetListViewState();
});
afterEach(() => {
wrapper?.unmount();
});
+ describe("list view state", () => {
+ const runsTable = (w: VueWrapper) =>
+ w.findAllComponents({ name: "OTable" }).find((t) => t.props("pagination") === "client")!;
+
+ it("restores page, page size and sort saved for this monitor", () => {
+ Object.assign(useListViewState("synthetic-runs:mon-1", {}), {
+ currentPage: 3,
+ pageSize: 20,
+ sortBy: "duration",
+ sortOrder: "asc",
+ });
+ wrapper = mountRuns();
+ const table = runsTable(wrapper);
+ expect(table.props("currentPage")).toBe(3);
+ expect(table.props("pageSize")).toBe(20);
+ expect(table.props("sortBy")).toBe("duration");
+ expect(table.props("sortOrder")).toBe("asc");
+ });
+
+ it("does not apply another monitor's saved view", () => {
+ Object.assign(useListViewState("synthetic-runs:mon-2", {}), { currentPage: 5 });
+ wrapper = mountRuns();
+ expect(runsTable(wrapper).props("currentPage")).toBe(1);
+ });
+
+ it("saves table changes back to the view state", async () => {
+ wrapper = mountRuns();
+ const table = runsTable(wrapper);
+ table.vm.$emit("update:currentPage", 2);
+ table.vm.$emit("update:sortBy", "status");
+ table.vm.$emit("update:sortOrder", "asc");
+ await flushPromises();
+ const saved = useListViewState("synthetic-runs:mon-1", {}) as Record;
+ expect(saved.currentPage).toBe(2);
+ expect(saved.sortBy).toBe("status");
+ expect(saved.sortOrder).toBe("asc");
+ });
+ });
+
describe("initial render", () => {
it("should render the runs dashboard shell", () => {
wrapper = mountRuns();
diff --git a/web/src/views/synthetics/MonitorRuns.vue b/web/src/views/synthetics/MonitorRuns.vue
index 1804bc7a47..e0a2dfa0de 100644
--- a/web/src/views/synthetics/MonitorRuns.vue
+++ b/web/src/views/synthetics/MonitorRuns.vue
@@ -721,15 +721,21 @@ along with this program. If not, see .
props.checkType === "browser");
@@ -1322,15 +1346,70 @@ const windowLabel = computed(() =>
? formatWindowLabel(timeRangeMicros.value.startTime, timeRangeMicros.value.endTime)
: t("synthetics.runs.window24h"),
);
-const statusFilter = ref("all");
-const browserFilter = ref("all");
-const deviceFilter = ref("all");
-const locationFilter = ref("all");
-const durationFilter = ref("all");
-const failedStepFilter = ref("all");
-const locatorFilter = ref("");
-const actionFilter = ref("all");
-const errorFilter = ref(null);
+const statusFilter = ref(savedView.statusFilter);
+const browserFilter = ref(savedView.browserFilter);
+const deviceFilter = ref(savedView.deviceFilter);
+const locationFilter = ref(savedView.locationFilter);
+const durationFilter = ref(savedView.durationFilter);
+const failedStepFilter = ref(savedView.failedStepFilter);
+const locatorFilter = ref(savedView.locatorFilter);
+const actionFilter = ref(savedView.actionFilter);
+const errorFilter = ref(savedView.errorFilter);
+const currentPage = ref(savedView.currentPage);
+const pageSize = ref(savedView.pageSize);
+const sortBy = ref(savedView.sortBy);
+const sortOrder = ref<"asc" | "desc">(savedView.sortOrder);
+const runsTableRef = ref(null);
+
+watch([statusFilter, browserFilter, deviceFilter, locationFilter, errorFilter], () => {
+ currentPage.value = 1;
+});
+watch(
+ [
+ activeTab,
+ statusFilter,
+ browserFilter,
+ deviceFilter,
+ locationFilter,
+ durationFilter,
+ failedStepFilter,
+ locatorFilter,
+ actionFilter,
+ errorFilter,
+ currentPage,
+ pageSize,
+ sortBy,
+ sortOrder,
+ ],
+ () => {
+ Object.assign(savedView, {
+ activeTab: activeTab.value,
+ statusFilter: statusFilter.value,
+ browserFilter: browserFilter.value,
+ deviceFilter: deviceFilter.value,
+ locationFilter: locationFilter.value,
+ durationFilter: durationFilter.value,
+ failedStepFilter: failedStepFilter.value,
+ locatorFilter: locatorFilter.value,
+ actionFilter: actionFilter.value,
+ errorFilter: errorFilter.value,
+ currentPage: currentPage.value,
+ pageSize: pageSize.value,
+ sortBy: sortBy.value,
+ sortOrder: sortOrder.value,
+ });
+ },
+);
+// The first runs load lands after mount and TanStack resets to page 0 on its own deferred queue; setTimeout(0) runs after it so the restored page wins.
+let pageRestored = false;
+watch(
+ () => synthetics.runsLoading.value,
+ (isLoading) => {
+ if (isLoading || pageRestored) return;
+ pageRestored = true;
+ setTimeout(() => runsTableRef.value?.restorePage?.(currentPage.value), 0);
+ },
+);
// ── Smart empty state helpers ──────────────────────────────────────────
const hasActiveFilters = computed(
diff --git a/web/src/views/synthetics/PrivateLocations.spec.ts b/web/src/views/synthetics/PrivateLocations.spec.ts
index 47ced00af9..2a31575550 100644
--- a/web/src/views/synthetics/PrivateLocations.spec.ts
+++ b/web/src/views/synthetics/PrivateLocations.spec.ts
@@ -42,6 +42,10 @@ vi.mock("vuex", () => ({
useStore: () => ({ state: { selectedOrganization: { identifier: "org-1" } } }),
}));
+vi.mock("@/stores", () => ({
+ default: { state: { selectedOrganization: { identifier: "org-1" } } },
+}));
+
vi.mock("@/utils/synthetics/format", () => ({
formatTimeAgoUs: vi.fn((us: number) => `formatted-${us}`),
}));
@@ -49,6 +53,7 @@ vi.mock("@/utils/synthetics/format", () => ({
// ── Component under test ───────────────────────────────────────────────────
import PrivateLocations from "./PrivateLocations.vue";
+import { resetListViewState, useListViewState } from "@/composables/useListViewState";
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -107,12 +112,21 @@ const OTableStub = {
"pagination",
"pageSize",
"pageSizeOptions",
+ "currentPage",
+ "sortBy",
+ "sortOrder",
"showGlobalFilter",
"persistColumns",
"tableId",
"enableColumnResize",
],
- emits: ["row-click"],
+ emits: [
+ "row-click",
+ "update:currentPage",
+ "update:pageSize",
+ "update:sortBy",
+ "update:sortOrder",
+ ],
};
const OButtonStub = {
@@ -176,12 +190,54 @@ describe("PrivateLocations", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRouterPush.mockReset();
+ resetListViewState();
});
afterEach(() => {
wrapper?.unmount();
});
+ describe("list view state", () => {
+ const seed = () =>
+ useListViewState("synthetics-private-locations", {
+ search: "",
+ currentPage: 1,
+ pageSize: 20,
+ sortBy: "",
+ sortOrder: "asc" as "asc" | "desc",
+ });
+
+ it("restores search, page, page size and sort", () => {
+ Object.assign(seed(), {
+ search: "eu",
+ currentPage: 2,
+ pageSize: 10,
+ sortBy: "label",
+ sortOrder: "desc",
+ });
+ wrapper = makeWrapper({ locations: [makeLocation({ label: "eu-1", region: "eu" })] });
+ const table = wrapper.findComponent(OTableStub);
+ expect(table.props("currentPage")).toBe(2);
+ expect(table.props("pageSize")).toBe(10);
+ expect(table.props("sortBy")).toBe("label");
+ expect(table.props("sortOrder")).toBe("desc");
+ expect((wrapper.find("input").element as HTMLInputElement).value).toBe("eu");
+ });
+
+ it("saves search and table changes", async () => {
+ wrapper = makeWrapper({ locations: [makeLocation()] });
+ await wrapper.find("input").setValue("abc");
+ const table = wrapper.findComponent(OTableStub);
+ table.vm.$emit("update:currentPage", 3);
+ table.vm.$emit("update:sortBy", "label");
+ await wrapper.vm.$nextTick();
+ const saved = seed();
+ expect(saved.search).toBe("abc");
+ expect(saved.currentPage).toBe(3);
+ expect(saved.sortBy).toBe("label");
+ });
+ });
+
describe("rendering", () => {
it("renders the table with the correct data-test attribute", () => {
wrapper = makeWrapper({ locations: [makeLocation()] });
diff --git a/web/src/views/synthetics/PrivateLocations.vue b/web/src/views/synthetics/PrivateLocations.vue
index 8bdc63bd1a..9a823ed6f2 100644
--- a/web/src/views/synthetics/PrivateLocations.vue
+++ b/web/src/views/synthetics/PrivateLocations.vue
@@ -17,19 +17,27 @@ along with this program. If not, see .
@@ -216,7 +224,7 @@ along with this program. If not, see .