diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts index aad3b63f5..116b25d26 100644 --- a/client/src/Features/UI/uiSlice.ts +++ b/client/src/Features/UI/uiSlice.ts @@ -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, diff --git a/client/src/Hooks/useMonitorListController.ts b/client/src/Hooks/useMonitorListController.ts index cd6852594..a7364cd60 100644 --- a/client/src/Hooks/useMonitorListController.ts +++ b/client/src/Hooks/useMonitorListController.ts @@ -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([]); @@ -36,8 +39,6 @@ export const useMonitorListController = (config: MonitorListConfig) => { const [selectedTags, setSelectedTags] = useState([]); const [page, setPage] = useState(0); const [search, setSearch] = useState(""); - const [sortField, setSortField] = useState(config.initialSortField ?? ""); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); const [selectedMonitor, setSelectedMonitor] = useState(null); const debouncedSearch = useDebounce(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, diff --git a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx index ea06557f6..714ca0c49 100644 --- a/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx +++ b/client/src/Pages/Docker/Monitors/Components/DockerMonitorsTable.tsx @@ -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[] => { diff --git a/client/src/Pages/Docker/Monitors/index.tsx b/client/src/Pages/Docker/Monitors/index.tsx index 88176a966..046f88092 100644 --- a/client/src/Pages/Docker/Monitors/index.tsx +++ b/client/src/Pages/Docker/Monitors/index.tsx @@ -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} diff --git a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx index 567fd3f9e..aaa9cb660 100644 --- a/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx +++ b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable.tsx @@ -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[] => { diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx index 5346f353c..e5bda316a 100644 --- a/client/src/Pages/Infrastructure/Monitors/index.tsx +++ b/client/src/Pages/Infrastructure/Monitors/index.tsx @@ -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} diff --git a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx index 9d1789264..a75adadf8 100644 --- a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx +++ b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx @@ -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[] => { diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx index 3eb597fa6..05ed838b3 100644 --- a/client/src/Pages/PageSpeed/Monitors/index.tsx +++ b/client/src/Pages/PageSpeed/Monitors/index.tsx @@ -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} /> diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 1d98ddc79..726938380 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -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 => { diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 642d871d3..f3321ce94 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -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} diff --git a/client/src/Types/Query.ts b/client/src/Types/Query.ts index 6c0b819b9..1eba5e1c7 100644 --- a/client/src/Types/Query.ts +++ b/client/src/Types/Query.ts @@ -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]; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index 4a2cee7a9..231cbbb94 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -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 = { http: "uptime", diff --git a/server/src/domain/monitors/monitor.repository.mongo.ts b/server/src/domain/monitors/monitor.repository.mongo.ts index 882bd5ccb..8935dc245 100644 --- a/server/src/domain/monitors/monitor.repository.mongo.ts +++ b/server/src/domain/monitors/monitor.repository.mongo.ts @@ -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 = { [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 => { diff --git a/server/test/integration/monitorRepository.test.ts b/server/test/integration/monitorRepository.test.ts index 63ef4a105..446085a0e 100644 --- a/server/test/integration/monitorRepository.test.ts +++ b/server/test/integration/monitorRepository.test.ts @@ -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();