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 && ( + + + + + )} + {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 && ( - - - - - )} - { - 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 && ( - - - - - )} - { - 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({