diff --git a/client/src/Components/monitors/MonitorListPage.tsx b/client/src/Components/monitors/MonitorListPage.tsx
new file mode 100644
index 000000000..8244f3875
--- /dev/null
+++ b/client/src/Components/monitors/MonitorListPage.tsx
@@ -0,0 +1,156 @@
+import { HeaderCreate } from "@/Components/common";
+import { ColoredLabel, MonitorBasePageWithStates } from "@/Components/design-elements";
+import { BulkActionsBar } from "@/Components/monitors/BulkActionsBar";
+import { ControlsFilter } from "@/Components/monitors/ControlsFilter";
+import { HeaderMonitorsSummary } from "@/Components/monitors/HeaderMonitorsSummary";
+import { useIsAdmin } from "@/Hooks/useIsAdmin";
+import type { MonitorListController } from "@/Hooks/useMonitorListController";
+import { useMediaQuery, useTheme } from "@mui/material";
+import { SPACING } from "@/Utils/Theme/constants";
+
+import Stack from "@mui/material/Stack";
+import Typography from "@mui/material/Typography";
+import { TextField, Button, Dialog } from "@/Components/inputs";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { Pause, Play } from "lucide-react";
+
+interface MonitorListPageProps {
+ headerKey: string;
+ page: string;
+ actionLink: string;
+ controller: MonitorListController;
+ bulkActions?: boolean;
+ showTypeFilter?: boolean;
+ summaryProps?: { showBreached?: boolean };
+ priorityFallback?: ReactNode;
+ extraLoading?: boolean;
+ extraError?: unknown;
+ children: ReactNode;
+}
+
+// Base monitor list page, all monitors pages are nearly identical, but with different tables.
+
+export const MonitorListPage = ({
+ headerKey,
+ page,
+ actionLink,
+ controller: c,
+ bulkActions,
+ showTypeFilter,
+ summaryProps,
+ priorityFallback,
+ extraLoading,
+ extraError,
+ children,
+}: MonitorListPageProps) => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const isSmall = useMediaQuery(theme.breakpoints.down("md"));
+ const isAdmin = useIsAdmin();
+ const isLoading = c.isLoading || Boolean(extraLoading);
+
+ return (
+
+
+
+
+
+ {
+ c.setSearch(event.target.value);
+ }}
+ />
+
+ {c.selectedTags.length > 0 && (
+
+
+ {t("pages.uptime.filters.activeTags")}
+
+ {c.selectedTags.map((tagId) => {
+ const tag = c.tags?.find((t) => t.id === tagId);
+ if (!tag) return null;
+ return (
+
+ );
+ })}
+
+ )}
+ {bulkActions && !isLoading && (
+
+ }
+ onClick={c.handleBulkResume}
+ >
+ {t("common.buttons.resume")}
+
+ }
+ onClick={c.handleBulkPause}
+ >
+ {t("common.buttons.pause")}
+
+
+ )}
+ {children}
+
+
+ );
+};
diff --git a/client/src/Components/monitors/index.tsx b/client/src/Components/monitors/index.tsx
index 46532706f..cf87eb074 100644
--- a/client/src/Components/monitors/index.tsx
+++ b/client/src/Components/monitors/index.tsx
@@ -15,3 +15,4 @@ export * from "./charts/HistogramPageSpeedDetailsTooltip";
export * from "./charts/HistogramInfrastructure";
export * from "./HeaderMonitorsSummary";
export * from "./BulkActionsBar";
+export * from "./MonitorListPage";
diff --git a/client/src/Features/UI/uiSlice.ts b/client/src/Features/UI/uiSlice.ts
index be54eead8..f759b2957 100644
--- a/client/src/Features/UI/uiSlice.ts
+++ b/client/src/Features/UI/uiSlice.ts
@@ -2,7 +2,13 @@ import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
type ThemeMode = "light" | "dark";
type ChartType = "histogram" | "line";
-type TableName = "monitors" | "team" | "maintenance" | "infrastructure" | "logs";
+type TableName =
+ | "monitors"
+ | "team"
+ | "maintenance"
+ | "infrastructure"
+ | "logs"
+ | "pagespeed";
interface TableState {
rowsPerPage: number;
@@ -14,6 +20,7 @@ interface SidebarState {
interface UIState {
monitors: TableState;
+ pagespeed: TableState;
team: TableState;
maintenance: TableState;
infrastructure: TableState;
@@ -37,6 +44,9 @@ const initialState: UIState = {
monitors: {
rowsPerPage: 10,
},
+ pagespeed: {
+ rowsPerPage: 10,
+ },
team: {
rowsPerPage: 5,
},
diff --git a/client/src/Hooks/useMonitorListController.ts b/client/src/Hooks/useMonitorListController.ts
new file mode 100644
index 000000000..cd6852594
--- /dev/null
+++ b/client/src/Hooks/useMonitorListController.ts
@@ -0,0 +1,209 @@
+import { setRowsPerPage, type TableName } from "@/Features/UI/uiSlice";
+import { useDelete, useGet } from "@/Hooks/UseApi";
+import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
+import useDebounce from "@/Hooks/useDebounce";
+import type { RootState } from "@/store";
+import {
+ type MonitorsWithChecksResponse,
+ SelectableMonitorTypes,
+ type Monitor,
+ type MonitorType,
+} from "@/Types/Monitor";
+import type { Tag } from "@/Types/Tag";
+import { useCallback, useMemo, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+
+interface MonitorListConfig {
+ types: MonitorType[] | "selectable";
+ checksLimit: number;
+ refreshInterval: number;
+ rowsPerPageTable: TableName;
+ rowsPerPageDefault: number;
+ initialSortField?: string; // default ""
+}
+
+export const useMonitorListController = (config: MonitorListConfig) => {
+ const dispatch = useDispatch();
+ const rowsPerPage = useSelector(
+ (state: RootState) =>
+ state.ui?.[config.rowsPerPageTable]?.rowsPerPage ?? config.rowsPerPageDefault
+ );
+
+ // Filter states
+ const [selectedTypes, setSelectedTypes] = useState([]);
+ const [selectedStatus, setSelectedStatus] = useState("");
+ const [selectedState, setSelectedState] = useState("");
+ 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);
+
+ // Convert filter selections to API filter values
+ // Status: pass "up"/"down" directly to the API
+ // State: "active" -> true, "paused" -> false
+ const toFilterStatus = useMemo(() => {
+ if (selectedStatus === "up") return "up";
+ if (selectedStatus === "down") return "down";
+ return undefined;
+ }, [selectedStatus]);
+
+ const toFilterActive = useMemo(() => {
+ if (selectedState === "active") return "true";
+ if (selectedState === "paused") return "false";
+ return undefined;
+ }, [selectedState]);
+
+ // Determine field and filter for the API request
+ // Priority: status > isActive > search > sort
+ const filterLookup = new Map([
+ [toFilterStatus, "status"],
+ [toFilterActive, "isActive"],
+ ]);
+ const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
+ const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
+ const filter = activeFilter?.[0] || debouncedSearch || undefined;
+
+ const effectiveTypes = useMemo(() => {
+ return config.types === "selectable"
+ ? selectedTypes.length > 0
+ ? selectedTypes
+ : [...SelectableMonitorTypes]
+ : config.types;
+ }, [config.types, selectedTypes]);
+
+ // Fetching
+ const monitorsWithChecksUrl = useMemo(() => {
+ const params = new URLSearchParams();
+ effectiveTypes.forEach((type) => params.append("type", type));
+ selectedTags.forEach((tag) => params.append("tags", tag));
+ params.append("limit", String(config.checksLimit));
+ if (page !== undefined) params.append("page", String(page));
+ if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
+ if (filter) params.append("filter", filter);
+ if (field) params.append("field", field);
+ if (sortOrder) params.append("order", sortOrder);
+ return `/monitors/team/with-checks?${params.toString()}`;
+ }, [
+ effectiveTypes,
+ selectedTags,
+ page,
+ rowsPerPage,
+ filter,
+ field,
+ sortOrder,
+ config.checksLimit,
+ ]);
+
+ const {
+ data: monitorsWithChecksData,
+ isLoading,
+ error,
+ refetch,
+ } = useGet(
+ monitorsWithChecksUrl,
+ {},
+ { refreshInterval: config.refreshInterval, keepPreviousData: true }
+ );
+ const { data: tags } = useGet("/tags/team");
+
+ const bulk = useBulkMonitorActions(refetch, page);
+
+ // Delete ops
+ const { deleteFn, loading: isDeleting } = useDelete();
+ const handleConfirmDelete = async () => {
+ if (!selectedMonitor) return;
+ await deleteFn(`/monitors/${selectedMonitor.id}`);
+ setSelectedMonitor(null);
+ refetch();
+ };
+ const handleCancelDelete = () => setSelectedMonitor(null);
+
+ // Handle filters
+ const handleClearFilters = useCallback(() => {
+ setSelectedTypes([]);
+ setSelectedStatus("");
+ setSelectedState("");
+ setSelectedTags([]);
+ setSearch("");
+ }, []);
+
+ const handleSetRowsPerPage = (value: number) => {
+ dispatch(setRowsPerPage({ value, table: config.rowsPerPageTable }));
+ setPage(0);
+ };
+
+ // Check for active filters
+ const hasActiveFilters = Boolean(
+ selectedTypes.length > 0 ||
+ selectedStatus ||
+ selectedState ||
+ selectedTags.length > 0 ||
+ search
+ );
+
+ // Show empty state only when there are truly no monitors (not just filtered out)
+ // If filters are active and count is 0, pass 1 to prevent empty state fallback
+
+ const { monitors, summary, count } = monitorsWithChecksData ?? {
+ monitors: null,
+ summary: null,
+ count: 0,
+ };
+
+ const effectiveTotalCount =
+ hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
+ ? 1
+ : (summary?.totalMonitors ?? 0);
+
+ return {
+ // Data
+ monitors,
+ summary,
+ count,
+ tags,
+ isLoading,
+ error,
+ refetch,
+ effectiveTotalCount,
+ hasActiveFilters,
+
+ // Filter state and setters
+ selectedTypes,
+ setSelectedTypes,
+ selectedStatus,
+ setSelectedStatus,
+ selectedState,
+ setSelectedState,
+ selectedTags,
+ setSelectedTags,
+ search,
+ setSearch,
+ handleClearFilters,
+
+ // Paging and Sorting
+ page,
+ setPage,
+ rowsPerPage,
+ handleSetRowsPerPage,
+ sortField,
+ setSortField,
+ sortOrder,
+ setSortOrder,
+
+ // Delete
+ selectedMonitor,
+ setSelectedMonitor,
+ isDialogOpen: Boolean(selectedMonitor),
+ isDeleting,
+ handleConfirmDelete,
+ handleCancelDelete,
+
+ // Rendered only when config.bulkActions
+ ...bulk,
+ };
+};
+
+export type MonitorListController = ReturnType;
diff --git a/client/src/Pages/Infrastructure/Monitors/index.tsx b/client/src/Pages/Infrastructure/Monitors/index.tsx
index aa41d2031..5346f353c 100644
--- a/client/src/Pages/Infrastructure/Monitors/index.tsx
+++ b/client/src/Pages/Infrastructure/Monitors/index.tsx
@@ -1,274 +1,44 @@
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-import useMediaQuery from "@mui/material/useMediaQuery";
-import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
-import { HeaderCreate } from "@/Components/common";
-import {
- ControlsFilter,
- HeaderMonitorsSummary,
- BulkActionsBar,
-} from "@/Components/monitors";
-import { TextField, Dialog, Button } from "@/Components/inputs";
-import { Play, Pause } from "lucide-react";
+import { MonitorListPage } from "@/Components/monitors";
+import { InfraMonitorsTable } from "@/Pages/Infrastructure/Monitors/Components/MonitorsTable";
-import { useGet, useDelete } from "@/Hooks/UseApi";
-import { useIsAdmin } from "@/Hooks/useIsAdmin";
-import type { Monitor, MonitorsWithChecksResponse } from "@/Types/Monitor";
-import type { Tag } from "@/Types/Tag";
-import { useTheme } from "@mui/material";
-import { SPACING } from "@/Utils/Theme/constants";
-import { useState, useCallback, useMemo } from "react";
-import { useTranslation } from "react-i18next";
-import { useSelector, useDispatch } from "react-redux";
-import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
-import type { RootState } from "@/Types/state";
-import { InfraMonitorsTable } from "./Components/MonitorsTable";
-import useDebounce from "@/Hooks/useDebounce";
-import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
const InfrastructureMonitors = () => {
- const { t } = useTranslation();
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- const isAdmin = useIsAdmin();
- const dispatch = useDispatch();
-
- // Redux state
- const rowsPerPage = useSelector(
- (state: RootState) => state.ui?.infrastructure?.rowsPerPage ?? 5
- );
-
- // Local state
- const [selectedStatus, setSelectedStatus] = useState("");
- const [selectedState, setSelectedState] = useState("");
- const [selectedTags, setSelectedTags] = useState([]);
- const [search, setSearch] = useState("");
- const [page, setPage] = useState(0);
- const [sortField, setSortField] = useState("");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const [selectedMonitor, setSelectedMonitor] = useState(null);
-
- const { data: tags } = useGet("/tags/team");
-
- const isDialogOpen = Boolean(selectedMonitor);
-
- const debouncedSearch = useDebounce(search, 300);
-
- const handleClearFilters = useCallback(() => {
- setSelectedStatus("");
- setSelectedState("");
- setSelectedTags([]);
- setSearch("");
- }, []);
-
- // Convert filter selections to API filter values
- const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "true";
- if (selectedStatus === "down") return "false";
- return undefined;
- }, [selectedStatus]);
-
- const toFilterActive = useMemo(() => {
- if (selectedState === "active") return "true";
- if (selectedState === "paused") return "false";
- return undefined;
- }, [selectedState]);
-
- // Determine field and filter for the API request
- // Priority: status > isActive > search
- const filterLookup = new Map([
- [toFilterStatus, "status"],
- [toFilterActive, "isActive"],
- ]);
- const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
- const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
- const filter = activeFilter?.[0] || debouncedSearch || undefined;
-
- // Build URL for monitors with checks
- const monitorsWithChecksUrl = useMemo(() => {
- const params = new URLSearchParams();
- params.append("type", "hardware");
- selectedTags.forEach((tagId) => params.append("tags", tagId));
- params.append("limit", "1");
- if (page !== undefined) params.append("page", String(page));
- if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (filter) params.append("filter", filter);
- if (field) params.append("field", field);
- if (sortOrder) params.append("order", sortOrder);
- return `/monitors/team/with-checks?${params.toString()}`;
- }, [page, rowsPerPage, filter, field, sortOrder, selectedTags]);
-
- const {
- data: monitorsWithChecksData,
- isLoading: monitorsWithChecksLoading,
- error: monitorsWithChecksError,
- refetch,
- } = useGet(
- monitorsWithChecksUrl,
- {},
- { refreshInterval: 5000, keepPreviousData: true }
- );
-
- const { summary, count } = monitorsWithChecksData ?? { summary: null, count: 0 };
- const isLoading = monitorsWithChecksLoading;
-
- // Bulk actions
- const {
- selectedRows,
- setSelectedRows,
- handleBulkPause,
- handleBulkResume,
- handleCancelSelection,
- } = useBulkMonitorActions(refetch, page);
-
- // Check if any filters are active
- const hasActiveFilters = Boolean(
- selectedStatus || selectedState || selectedTags.length > 0 || search
- );
-
- // Show empty state only when there are truly no monitors (not just filtered out)
- const effectiveTotalCount =
- hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
- ? 1
- : (summary?.totalMonitors ?? 0);
-
- // Delete hook
- const { deleteFn, loading: isDeleting } = useDelete();
-
- const handleConfirm = async () => {
- if (!selectedMonitor) return;
- await deleteFn(`/monitors/${selectedMonitor.id}`);
- setSelectedMonitor(null);
- refetch();
- };
-
- const handleCancel = () => {
- setSelectedMonitor(null);
- };
-
+ const c = useMonitorListController({
+ types: ["hardware"],
+ checksLimit: 1,
+ refreshInterval: 5000,
+ rowsPerPageTable: "infrastructure",
+ rowsPerPageDefault: 5,
+ });
return (
-
-
-
-
-
- {
- setSearch(event.target.value);
- }}
- />
-
-
- {selectedTags.length > 0 && (
-
-
- {t("pages.uptime.filters.activeTags")}
-
- {selectedTags.map((tagId) => {
- const tag = tags?.find((t) => t.id === tagId);
- if (!tag) return null;
- return (
-
- );
- })}
-
- )}
-
- {!isLoading && (
-
- }
- onClick={handleBulkResume}
- >
- {t("common.buttons.resume")}
-
- }
- onClick={handleBulkPause}
- >
- {t("common.buttons.pause")}
-
-
- )}
-
{
- dispatch(
- setRowsPerPage({
- value,
- table: "infrastructure",
- })
- );
- setPage(0);
- }}
- selectedRows={selectedRows}
- onSelectionChange={setSelectedRows}
+ monitors={c.monitors || []}
+ tags={c.tags || []}
+ refetch={c.refetch}
+ setSelectedMonitor={c.setSelectedMonitor}
+ count={c.count || 0}
+ page={c.page}
+ setPage={c.setPage}
+ rowsPerPage={c.rowsPerPage}
+ sortField={c.sortField}
+ setSortField={c.setSortField}
+ sortOrder={c.sortOrder}
+ setSortOrder={c.setSortOrder}
+ setRowsPerPage={c.handleSetRowsPerPage}
+ selectedRows={c.selectedRows}
+ onSelectionChange={c.setSelectedRows}
/>
-
-
+
);
};
diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx
index b9d0b49b1..3eb597fa6 100644
--- a/client/src/Pages/PageSpeed/Monitors/index.tsx
+++ b/client/src/Pages/PageSpeed/Monitors/index.tsx
@@ -1,229 +1,57 @@
-import {
- MonitorBasePageWithStates,
- PageSpeedKeyPriorityFallback,
- ColoredLabel,
-} from "@/Components/design-elements";
-import { Dialog, TextField } from "@/Components/inputs";
-import { HeaderCreate } from "@/Components/common";
+import { PageSpeedKeyPriorityFallback } from "@/Components/design-elements";
import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable";
-import type { Monitor } from "@/Types/Monitor";
-import type { Tag } from "@/Types/Tag";
-import Stack from "@mui/material/Stack";
-import Typography from "@mui/material/Typography";
-import useMediaQuery from "@mui/material/useMediaQuery";
-import { useTheme } from "@mui/material";
-import { SPACING } from "@/Utils/Theme/constants";
-
-import { useTranslation } from "react-i18next";
-import { useCallback, useMemo, useState } from "react";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
-import { useGet, useDelete } from "@/Hooks/UseApi";
-import useDebounce from "@/Hooks/useDebounce";
-import type { MonitorsWithChecksResponse } from "@/Types/Monitor";
+import { useGet } from "@/Hooks/UseApi";
import type { AppSettingsResponse } from "@/Types/Settings";
-import { ControlsFilter, HeaderMonitorsSummary } from "@/Components/monitors";
+import { MonitorListPage } from "@/Components/monitors";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
const PageSpeedMonitorsPage = () => {
- const { t } = useTranslation();
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
const isAdmin = useIsAdmin();
- const { deleteFn, loading: isDeleting } = useDelete();
-
- const settingsUrl = "/settings";
const {
data: settingsData,
isLoading: settingsIsLoading,
error: settingsError,
- } = useGet(settingsUrl);
+ } = useGet("/settings");
- const [selectedMonitor, setSelectedMonitor] = useState(null);
- const isDialogOpen = Boolean(selectedMonitor);
- const [sortField, setSortField] = useState("name");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const [page, setPage] = useState(0);
- const [rowsPerPage, setRowsPerPage] = useState(10);
- const [selectedTags, setSelectedTags] = useState([]);
- const [selectedStatus, setSelectedStatus] = useState("");
- const [selectedState, setSelectedState] = useState("");
- const [search, setSearch] = useState("");
-
- const debouncedSearch = useDebounce(search, 300);
-
- // Status: pass "up"/"down" directly to the API
- // State: "active" -> true, "paused" -> false
- const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "up";
- if (selectedStatus === "down") return "down";
- return undefined;
- }, [selectedStatus]);
-
- const toFilterActive = useMemo(() => {
- if (selectedState === "active") return "true";
- if (selectedState === "paused") return "false";
- return undefined;
- }, [selectedState]);
-
- // Priority: status > isActive > search > sort
- const filterLookup = new Map([
- [toFilterStatus, "status"],
- [toFilterActive, "isActive"],
- ]);
- const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
- const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
- const filter = activeFilter?.[0] || debouncedSearch;
-
- const { data: tags } = useGet("/tags/team");
-
- const monitorsWithChecksUrl = useMemo(() => {
- const params = new URLSearchParams();
- params.append("type", "pagespeed");
- selectedTags.forEach((tagId) => params.append("tags", tagId));
- params.append("limit", "25");
- if (page !== undefined) params.append("page", String(page));
- if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (filter) params.append("filter", filter);
- if (field) params.append("field", field);
- if (sortOrder) params.append("order", sortOrder);
- return `/monitors/team/with-checks?${params.toString()}`;
- }, [page, rowsPerPage, sortOrder, selectedTags, filter, field]);
-
- const {
- data: monitorsData,
- isLoading: monitorsIsLoading,
- error: monitorsError,
- refetch,
- } = useGet(
- monitorsWithChecksUrl,
- {},
- { refreshInterval: 30000 }
- );
-
- const monitors = monitorsData?.monitors;
- const monitorsCount = monitorsData?.count ?? 0;
- const summary = monitorsData?.summary ?? null;
-
- const isLoading = monitorsIsLoading || settingsIsLoading;
+ const c = useMonitorListController({
+ types: ["pagespeed"],
+ checksLimit: 25,
+ refreshInterval: 30000,
+ rowsPerPageTable: "pagespeed",
+ rowsPerPageDefault: 10,
+ initialSortField: "name",
+ });
const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet;
- const handleConfirm = async () => {
- if (!selectedMonitor) return;
- await deleteFn(`/monitors/${selectedMonitor.id}`);
- setSelectedMonitor(null);
- refetch();
- };
-
- const handleCancel = () => {
- setSelectedMonitor(null);
- };
-
- const handleClearFilters = useCallback(() => {
- setSelectedStatus("");
- setSelectedState("");
- setSelectedTags([]);
- setSearch("");
- }, []);
-
- const hasActiveFilters = Boolean(
- selectedStatus || selectedState || selectedTags.length > 0 || search
- );
-
- const effectiveTotalCount =
- hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
- ? 1
- : (summary?.totalMonitors ?? 0);
-
return (
- : undefined}
>
-
-
-
-
-
- {
- setSearch(event.target.value);
- }}
- />
-
-
- {selectedTags.length > 0 && (
-
-
- {t("pages.uptime.filters.activeTags")}
-
- {selectedTags.map((tagId) => {
- const tag = tags?.find((t) => t.id === tagId);
- if (!tag) return null;
- return (
-
- );
- })}
-
- )}
-
-
-
+
);
};
diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx
index 021e02ac5..f7664c1b7 100644
--- a/client/src/Pages/Uptime/Monitors/index.tsx
+++ b/client/src/Pages/Uptime/Monitors/index.tsx
@@ -1,294 +1,43 @@
-import {
- ControlsFilter,
- HeaderMonitorsSummary,
- BulkActionsBar,
-} from "@/Components/monitors";
-import { MonitorBasePageWithStates, ColoredLabel } from "@/Components/design-elements";
-import { TextField, Dialog, Button } from "@/Components/inputs";
-import Stack from "@mui/material/Stack";
+import { MonitorListPage } from "@/Components/monitors";
import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable";
-import { HeaderCreate } from "@/Components/common";
-import { Play, Pause } from "lucide-react";
-import Typography from "@mui/material/Typography";
-import { useTranslation } from "react-i18next";
-import useMediaQuery from "@mui/material/useMediaQuery";
-import { useGet, useDelete } from "@/Hooks/UseApi";
-import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor";
-import type { Tag } from "@/Types/Tag";
-import { useState, useMemo, useCallback } from "react";
-import { useSelector, useDispatch } from "react-redux";
-import { setRowsPerPage } from "@/Features/UI/uiSlice.js";
-import { useIsAdmin } from "@/Hooks/useIsAdmin";
-import type { RootState } from "@/Types/state";
-import { useTheme } from "@mui/material";
-import useDebounce from "@/Hooks/useDebounce";
-import { useBulkMonitorActions } from "@/Hooks/useBulkMonitorActions";
-import { SPACING } from "@/Utils/Theme/constants";
+import { useMonitorListController } from "@/Hooks/useMonitorListController";
const UptimeMonitorsPage = () => {
- const { t } = useTranslation();
- const theme = useTheme();
- const isSmall = useMediaQuery(theme.breakpoints.down("md"));
- const dispatch = useDispatch();
- const isAdmin = useIsAdmin();
- // Redux state
- const rowsPerPage = useSelector(
- (state: RootState) => state.ui?.monitors?.rowsPerPage ?? 10
- );
-
- // Local state
- const [selectedTypes, setSelectedTypes] = useState([]);
- const [selectedStatus, setSelectedStatus] = useState("");
- const [selectedState, setSelectedState] = useState("");
- const [selectedTags, setSelectedTags] = useState([]);
- const [page, setPage] = useState(0);
- const [search, setSearch] = useState("");
- const [sortField, setSortField] = useState("");
- const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
- const [selectedMonitor, setSelectedMonitor] = useState(null);
-
- const isDialogOpen = Boolean(selectedMonitor);
-
- const debouncedSearch = useDebounce(search, 300);
-
- // Convert filter selections to API filter values
- // Status: pass "up"/"down" directly to the API
- // State: "active" -> true, "paused" -> false
- const toFilterStatus = useMemo(() => {
- if (selectedStatus === "up") return "up";
- if (selectedStatus === "down") return "down";
- return undefined;
- }, [selectedStatus]);
-
- const toFilterActive = useMemo(() => {
- if (selectedState === "active") return "true";
- if (selectedState === "paused") return "false";
- return undefined;
- }, [selectedState]);
-
- // Determine field and filter for the API request
- // Priority: status > isActive > search > sort
- const filterLookup = new Map([
- [toFilterStatus, "status"],
- [toFilterActive, "isActive"],
- ]);
- const activeFilter = [...filterLookup].find(([key]) => key !== undefined);
- const field = activeFilter?.[1] || (debouncedSearch ? "name" : sortField || undefined);
- const filter = activeFilter?.[0] || debouncedSearch;
-
- // Default to all types when none selected
- const effectiveTypes =
- selectedTypes.length > 0
- ? selectedTypes
- : ["http", "ping", "docker", "port", "game", "grpc", "websocket", "dns"];
-
- // Build URL for monitors with checks
- const monitorsWithChecksUrl = useMemo(() => {
- const params = new URLSearchParams();
- effectiveTypes.forEach((type) => params.append("type", type));
- selectedTags.forEach((tagId) => params.append("tags", tagId));
- params.append("limit", "25");
- if (page !== undefined) params.append("page", String(page));
- if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage));
- if (filter) params.append("filter", filter);
- if (field) params.append("field", field);
- if (sortOrder) params.append("order", sortOrder);
- return `/monitors/team/with-checks?${params.toString()}`;
- }, [effectiveTypes, selectedTags, page, rowsPerPage, filter, field, sortOrder]);
-
- const {
- data: monitorsWithChecksData,
- isLoading: monitorsWithChecksLoading,
- error: monitorsWithChecksError,
- refetch,
- } = useGet(
- monitorsWithChecksUrl,
- {},
- { refreshInterval: 5000, keepPreviousData: true }
- );
-
- const { data: tags } = useGet("/tags/team");
-
- const {
- monitors: monitorsWithChecks,
- summary,
- count,
- } = monitorsWithChecksData ?? { monitors: null, summary: null, count: 0 };
-
- // Bulk actions
- const {
- selectedRows,
- setSelectedRows,
- handleBulkPause,
- handleBulkResume,
- handleCancelSelection,
- } = useBulkMonitorActions(refetch, page);
-
- // Delete hook
- const { deleteFn, loading: isDeleting } = useDelete();
-
- // Handlers
- const handleClearFilters = useCallback(() => {
- setSelectedTypes([]);
- setSelectedStatus("");
- setSelectedState("");
- setSelectedTags([]);
- setSearch("");
- }, []);
-
- const handleConfirm = async () => {
- if (!selectedMonitor) return;
- await deleteFn(`/monitors/${selectedMonitor.id}`);
- setSelectedMonitor(null);
- refetch();
- };
-
- const handleCancel = () => {
- setSelectedMonitor(null);
- };
-
- const isLoading = monitorsWithChecksLoading;
-
- // Check if any filters are active
- const hasActiveFilters = Boolean(
- selectedTypes.length > 0 ||
- selectedStatus ||
- selectedState ||
- selectedTags.length > 0 ||
- search
- );
-
- // Show empty state only when there are truly no monitors (not just filtered out)
- // If filters are active and count is 0, pass 1 to prevent empty state fallback
- const effectiveTotalCount =
- hasActiveFilters && (summary?.totalMonitors ?? 0) === 0
- ? 1
- : (summary?.totalMonitors ?? 0);
+ const c = useMonitorListController({
+ types: "selectable",
+ checksLimit: 25,
+ refreshInterval: 5000,
+ rowsPerPageTable: "monitors",
+ rowsPerPageDefault: 10,
+ });
return (
-
-
-
-
-
-
-
- {
- setSearch(event.target.value);
- }}
- />
-
-
- {selectedTags.length > 0 && (
-
-
- {t("pages.uptime.filters.activeTags")}
-
- {selectedTags.map((tagId) => {
- const tag = tags?.find((t) => t.id === tagId);
- if (!tag) return null;
- return (
-
- );
- })}
-
- )}
-
- {!isLoading && (
-
- }
- onClick={handleBulkResume}
- >
- {t("common.buttons.resume")}
-
- }
- onClick={handleBulkPause}
- >
- {t("common.buttons.pause")}
-
-
- )}
-
{
- dispatch(
- setRowsPerPage({
- value: rowsPerPage,
- table: "monitors",
- })
- );
- setPage(0);
- }}
- selectedRows={selectedRows}
- onSelectionChange={setSelectedRows}
+ monitors={c.monitors || []}
+ tags={c.tags || []}
+ refetch={c.refetch}
+ setSelectedMonitor={c.setSelectedMonitor}
+ count={c.count || 0}
+ page={c.page}
+ setPage={c.setPage}
+ rowsPerPage={c.rowsPerPage}
+ sortField={c.sortField}
+ setSortField={c.setSortField}
+ sortOrder={c.sortOrder}
+ setSortOrder={c.setSortOrder}
+ setRowsPerPage={c.handleSetRowsPerPage}
+ selectedRows={c.selectedRows}
+ onSelectionChange={c.setSelectedRows}
/>
-
-
+
);
};
diff --git a/client/src/store.ts b/client/src/store.ts
index 9e064b55f..d2a1b96d8 100644
--- a/client/src/store.ts
+++ b/client/src/store.ts
@@ -2,6 +2,7 @@ import { configureStore, combineReducers } from "@reduxjs/toolkit";
import authReducer from "@/Features/Auth/authSlice";
import uiReducer from "@/Features/UI/uiSlice";
import storage from "redux-persist/lib/storage";
+import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2";
import {
persistReducer,
persistStore,
@@ -24,6 +25,8 @@ const persistConfig = {
storage,
whitelist: ["auth", "ui"],
transforms: [authTransform],
+ // Merge two levels deep on rehydrate so a slice key added in a new release keeps initial value
+ stateReconciler: autoMergeLevel2,
};
const rootReducer = combineReducers({