Merge pull request #3985 from fabiangigler/feat/persist-monitor-sorting

Feat/persist monitor sorting
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-09-25 10:57:45 -07:00
commit a258910ccf
14 files changed
+105 -70

No files matched your search

+12
View File
@@ -3,6 +3,7 @@ import {
type DashboardCardKey,
type DashboardSortOrder,
} from "@/Types/Dashboard";
import type { SortOrder } from "@/Types/Query";
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
@@ -19,6 +20,8 @@ type TableName =
interface TableState {
rowsPerPage: number;
sortField?: string;
sortOrder?: SortOrder;
}
interface SidebarState {
@@ -100,6 +103,14 @@ const uiSlice = createSlice({
const { table, value } = action.payload;
state[table].rowsPerPage = value;
},
setTableSort: (
state,
action: PayloadAction<{ table: TableName; field: string; order: SortOrder }>
) => {
const { table, field, order } = action.payload;
state[table].sortField = field;
state[table].sortOrder = order;
},
toggleSidebar: (state) => {
state.sidebar.collapsed = !state.sidebar.collapsed;
},
@@ -140,6 +151,7 @@ export type { UIState, ThemeMode, ChartType, TableName };
export default uiSlice.reducer;
export const {
setRowsPerPage,
setTableSort,
toggleSidebar,
setCollapsed,
setMode,
+13 -8
View File
@@ -1,4 +1,4 @@
import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
import { setRowsPerPage, setTableSort, type TableName } from "@/Features/UI/uiSlice";
import { useDelete, useGet } from "@/Hooks/UseApi";
import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
import useDebounce from "@/Hooks/useDebounce";
@@ -10,6 +10,7 @@ import {
type MonitorType,
} from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
@@ -24,10 +25,12 @@ interface MonitorListConfig {
export const useMonitorListController = (config: MonitorListConfig) => {
const dispatch = useDispatch();
const rowsPerPage = useSelector(
(state: RootState) =>
state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
const tableState = useSelector(
(state: RootState) => state.ui?.[config.rowsPerPageTable]
);
const rowsPerPage = tableState?.rowsPerPage ?? config.rowsPerPageDefault;
const sortField = tableState?.sortField ?? config.initialSortField ?? "";
const sortOrder = tableState?.sortOrder ?? "asc";
// Filter states
const [selectedTypes, setSelectedTypes] = useState<MonitorType[]>([]);
@@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [page, setPage] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const [sortField, setSortField] = useState<string>(config.initialSortField ?? "");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedMonitor, setSelectedMonitor] = useState<Monitor | null>(null);
const debouncedSearch = useDebounce<string>(search, 300);
@@ -135,6 +136,11 @@ export const useMonitorListController = (config: MonitorListConfig) => {
setPage(0);
};
const setSort = (field: string, order: SortOrder) => {
dispatch(setTableSort({ field, order, table: config.rowsPerPageTable }));
setPage(0);
};
// Check for active filters
const hasActiveFilters = Boolean(
selectedTypes.length > 0 ||
@@ -189,9 +195,8 @@ export const useMonitorListController = (config: MonitorListConfig) => {
rowsPerPage,
handleSetRowsPerPage,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
// Delete
selectedMonitor,
@@ -8,6 +8,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -20,6 +21,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { DockerContainerSummary } from "@/Types/Check";
import { Checkbox } from "@/Components/inputs";
@@ -34,9 +36,8 @@ interface DockerMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,9 +53,8 @@ export const DockerMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -105,14 +105,8 @@ export const DockerMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
+1 -2
View File
@@ -31,9 +31,8 @@ const DockerMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import type { Header } from "@/Components/design-elements/Table";
import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
import { ArrowUp, ArrowDown } from "lucide-react";
@@ -21,6 +22,7 @@ import { usePost } from "@/Hooks/UseApi";
import type { Monitor } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { Checkbox } from "@/Components/inputs";
interface InfraMonitorsTableProps {
@@ -29,9 +31,8 @@ interface InfraMonitorsTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -47,9 +48,8 @@ export const InfraMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -100,14 +100,8 @@ export const InfraMonitorsTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -31,9 +31,8 @@ const InfrastructureMonitors = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
selectedRows={c.selectedRows}
onSelectionChange={c.setSelectedRows}
@@ -9,6 +9,7 @@ import {
ColoredLabel,
} from "@/Components/design-elements";
import { LAYOUT, SPACING } from "@/Utils/Theme/constants";
import { getNextMonitorSort } from "@/Utils/MonitorUtils";
import { HistogramPageSpeed } from "@/Components/monitors";
import { ArrowDown, ArrowUp } from "lucide-react";
import type { Header } from "@/Components/design-elements/Table";
@@ -22,6 +23,7 @@ import { usePatch } from "@/Hooks/UseApi";
import type { Monitor, MonitorWithChecks } from "@/Types/Monitor";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import type { ActionMenuItem } from "@/Components/actions-menu";
export const PageSpeedMonitorsTable = ({
@@ -30,9 +32,8 @@ export const PageSpeedMonitorsTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -44,9 +45,8 @@ export const PageSpeedMonitorsTable = ({
refetch: Function;
setSelectedMonitor: Function;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -81,14 +81,8 @@ export const PageSpeedMonitorsTable = ({
const handleSort = (e: any, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const getActions = (monitor: Monitor): ActionMenuItem[] => {
@@ -46,9 +46,8 @@ const PageSpeedMonitorsPage = () => {
setPage={c.setPage}
rowsPerPage={c.rowsPerPage}
sortField={c.sortField}
setSortField={c.setSortField}
sortOrder={c.sortOrder}
setSortOrder={c.setSortOrder}
setSort={c.setSort}
setRowsPerPage={c.handleSetRowsPerPage}
/>
</MonitorListPage>
@@ -24,8 +24,9 @@ import type { ActionMenuItem } from "@/Components/actions-menu";
import type { RootState } from "@/Types/state";
import { Checkbox } from "@/Components/inputs";
import type { Tag } from "@/Types/Tag";
import type { SortOrder } from "@/Types/Query";
import { SPACING } from "@/Utils/Theme/constants";
import { getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { getNextMonitorSort, getUptimePercentageColor } from "@/Utils/MonitorUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
interface MonitorTableProps {
@@ -34,9 +35,8 @@ interface MonitorTableProps {
refetch: () => void;
setSelectedMonitor: (monitor: Monitor | null) => void;
sortField: string;
setSortField: (field: string) => void;
sortOrder: "asc" | "desc";
setSortOrder: (order: "asc" | "desc") => void;
sortOrder: SortOrder;
setSort: (field: string, order: SortOrder) => void;
count: number;
page: number;
setPage: (page: number) => void;
@@ -52,9 +52,8 @@ export const MonitorTable = ({
refetch,
setSelectedMonitor,
sortField,
setSortField,
sortOrder,
setSortOrder,
setSort,
count,
page,
setPage,
@@ -91,14 +90,8 @@ export const MonitorTable = ({
const handleSort = (e: React.MouseEvent, field: string) => {
e.preventDefault();
e.stopPropagation();
if (sortField === field) {
const newOrder = sortOrder === "asc" ? "desc" : "asc";
setSortOrder(newOrder);
} else {
setSortField(field);
setSortOrder("asc");
}
refetch();
const nextSort = getNextMonitorSort(field, sortField, sortOrder);
setSort(nextSort.field, nextSort.order);
};
const isBrowserOpenable = (monitor: Monitor): boolean => {
+1 -2
View File
@@ -30,9 +30,8 @@ const UptimeMonitorsPage = () => {
setPage={monitorListController.setPage}
rowsPerPage={monitorListController.rowsPerPage}
sortField={monitorListController.sortField}
setSortField={monitorListController.setSortField}
sortOrder={monitorListController.sortOrder}
setSortOrder={monitorListController.setSortOrder}
setSort={monitorListController.setSort}
setRowsPerPage={monitorListController.handleSetRowsPerPage}
selectedRows={monitorListController.selectedRows}
onSelectionChange={monitorListController.setSelectedRows}
+3
View File
@@ -1,2 +1,5 @@
export const DateRanges = ["recent", "hour", "day", "week", "month", "all"] as const;
export type DateRange = (typeof DateRanges)[number];
export const SortOrders = ["asc", "desc"] as const;
export type SortOrder = (typeof SortOrders)[number];
+11
View File
@@ -1,4 +1,5 @@
import type { MonitorStatus, MonitorType } from "@/Types/Monitor";
import type { SortOrder } from "@/Types/Query";
import type { PaletteKey } from "@/Utils/Theme/Theme";
import type { ValueType } from "@/Components/design-elements/StatusLabel";
import {
@@ -10,6 +11,16 @@ import {
type DockerLogLine,
} from "@/Types/Check";
export const getNextMonitorSort = (
field: string,
sortField: string,
sortOrder: SortOrder
): { field: string; order: SortOrder } => {
if (field !== sortField) return { field, order: "asc" };
if (sortOrder === "asc") return { field, order: "desc" };
return { field: "", order: "asc" };
};
export const getMonitorPath = (type: MonitorType): string => {
const pathMap: Record<MonitorType, string> = {
http: "uptime",
@@ -101,7 +101,12 @@ class MongoMonitorsRepository implements IMonitorsRepository {
private pageOptions = (config: TeamQueryConfig) => {
const { page = 0, rowsPerPage = 0, field = "createdAt", order = "desc" } = config ?? {};
return { sort: { [field]: order === "asc" ? 1 : -1 } as const, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
const direction: 1 | -1 = order === "asc" ? 1 : -1;
const sort: Record<string, 1 | -1> = { [field]: direction };
if (field !== "createdAt") {
sort.createdAt = 1;
}
return { sort, skip: Math.max(page, 0) * rowsPerPage, limit: rowsPerPage };
};
findByTeamId = async (teamId: string, config: TeamQueryConfig, options?: { includeRecentChecks?: boolean }): Promise<Monitor[]> => {
@@ -69,6 +69,34 @@ describe("MongoMonitorsRepository", () => {
});
});
describe("sorting", () => {
it("uses creation time as the fallback for equal primary values", async () => {
const repo = new MongoMonitorsRepository();
const teamId = new mongoose.Types.ObjectId();
const createMonitor = (name: string, type: string, status: string, createdAt: Date) =>
MonitorModel.create({
userId: new mongoose.Types.ObjectId(),
teamId,
name,
type,
status,
url: "https://example.com",
createdAt,
});
await createMonitor("newer-up", "hardware", "up", new Date("2026-03-01T00:00:00.000Z"));
await createMonitor("down", "http", "down", new Date("2026-04-01T00:00:00.000Z"));
await createMonitor("older-up", "pagespeed", "up", new Date("2026-01-01T00:00:00.000Z"));
await createMonitor("middle-up", "docker", "up", new Date("2026-02-01T00:00:00.000Z"));
const ascending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "asc" });
const descending = await repo.findByTeamIdWithStats(teamId.toString(), { field: "status", order: "desc" });
expect(ascending.map((monitor) => monitor.name)).toEqual(["down", "older-up", "middle-up", "newer-up"]);
expect(descending.map((monitor) => monitor.name)).toEqual(["older-up", "middle-up", "newer-up", "down"]);
});
});
describe("findByIds recentChecks modes", () => {
const seedFixtures = async () => {
const teamId = new mongoose.Types.ObjectId();