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({