diff --git a/client/src/Components/v2/design-elements/BasePage.tsx b/client/src/Components/v2/design-elements/BasePage.tsx index c8618fa67..de20fc005 100644 --- a/client/src/Components/v2/design-elements/BasePage.tsx +++ b/client/src/Components/v2/design-elements/BasePage.tsx @@ -1,15 +1,51 @@ import Stack from "@mui/material/Stack"; +import Alert from "@mui/material/Alert"; +import Link from "@mui/material/Link"; import { ErrorFallback, EmptyFallback, EmptyMonitorFallback, + BaseFallback, } from "@/Components/v2/design-elements/Fallback"; import { Breadcrumb } from "@/Components/v2/design-elements/Breadcrumb"; import CircularProgress from "@mui/material/CircularProgress"; import type { StackProps } from "@mui/material/Stack"; import { useTheme } from "@mui/material/styles"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; +import { Link as RouterLink } from "react-router-dom"; +import { Typography } from "@mui/material"; + +export const PageSpeedKeyPriorityFallback = () => { + return ( + + + + + ), + }} + /> + + + + ); +}; + interface BasePageProps extends StackProps { loading?: boolean; error?: boolean; @@ -114,6 +150,7 @@ interface MonitorBasePageWithStatesProps extends StackProps { page: string; actionLink?: string; children: React.ReactNode; + priorityFallback?: React.ReactNode; } const isEmpty = (items: any[]) => { @@ -129,12 +166,25 @@ export const MonitorBasePageWithStates = ({ page, actionLink, children, + priorityFallback, ...props }: MonitorBasePageWithStatesProps) => { const { t } = useTranslation(); const showLoading = loading && (!items || items.length === 0); + if (priorityFallback) { + return ( + + {priorityFallback} + + ); + } + if (!loading && isEmpty(items)) { return ( { + const { t } = useTranslation(); + return ( + + {title} + + {content} + + + + + + + ); +}; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 87698a4f0..70e740dbd 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -8,3 +8,4 @@ export { ToggleButtonInput as ToggleButton, ToggleButtonGroupInput as ToggleButtonGroup, } from "./ToggleButton"; +export { DialogInput as Dialog } from "./Dialog"; diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeed.tsx b/client/src/Components/v2/monitors/charts/HistogramPageSpeed.tsx new file mode 100644 index 000000000..d8d3b9829 --- /dev/null +++ b/client/src/Components/v2/monitors/charts/HistogramPageSpeed.tsx @@ -0,0 +1,107 @@ +import Stack from "@mui/material/Stack"; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip } from "recharts"; +import { HistogramPageSpeedTooltip } from "@/Components/v2/monitors/charts/HistogramPageSpeedTooltip"; + +import { useTheme } from "@mui/material/styles"; +import type { Check } from "@/Types/Check"; +import type { MonitorStatus } from "@/Types/Monitor"; + +import { getStatusColor } from "@/Utils/MonitorUtils"; + +const processData = (checks: Check[]) => { + if (checks.length === 0) return []; + const formattedData: { score: number; date: string }[] = []; + + const calculateScore = (entry: Check) => { + const accessibility = entry?.accessibility || 0; + const bestPractices = entry?.bestPractices || 0; + const performance = entry?.performance || 0; + const seo = entry?.seo || 0; + return Math.floor(((accessibility + bestPractices + performance + seo) / 4) * 100); + }; + + checks.forEach((entry) => { + const score = calculateScore(entry); + const date = entry.createdAt; + formattedData.push({ score, date }); + }); + + return formattedData; +}; + +export const HistogramPageSpeed = ({ + checks, + status, +}: { + checks: Check[]; + status: MonitorStatus; +}) => { + const theme = useTheme(); + const scores = processData(checks ?? []); + const color = getStatusColor(status, theme); + return ( + event.stopPropagation()} + overflow={"hidden"} + sx={{ + cursor: "default", + position: "relative", + }} + > + + + + } + /> + + + + + + + + + + + ); +}; diff --git a/client/src/Components/v2/monitors/charts/HistogramPageSpeedTooltip.tsx b/client/src/Components/v2/monitors/charts/HistogramPageSpeedTooltip.tsx new file mode 100644 index 000000000..6fd18251c --- /dev/null +++ b/client/src/Components/v2/monitors/charts/HistogramPageSpeedTooltip.tsx @@ -0,0 +1,40 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { TooltipProps } from "recharts"; +import { useTheme } from "@mui/material/styles"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; + +export const HistogramPageSpeedTooltip = ({ + payload, +}: TooltipProps) => { + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const theme = useTheme(); + return ( + + + {formatDateWithTz( + payload?.[0]?.payload?.date ?? 0, + "MMMM D, YYYY, HH:mm A", + uiTimezone + )} + + + Score: {payload?.[0]?.payload?.score} + + + ); +}; diff --git a/client/src/Components/v2/monitors/index.tsx b/client/src/Components/v2/monitors/index.tsx index de38d2cab..ca0aaa15e 100644 --- a/client/src/Components/v2/monitors/index.tsx +++ b/client/src/Components/v2/monitors/index.tsx @@ -3,3 +3,5 @@ export * from "./MonitorStatBoxes"; export * from "./charts/HistogramStatus"; export * from "./charts/RadialAvgResponse"; export * from "./charts/HistogramDetails"; +export * from "./charts/HistogramPageSpeed"; +export * from "./charts/HistogramPageSpeedTooltip"; diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index 441c2b77b..aa71f01af 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -1,7 +1,7 @@ import useSWR from "swr"; import type { SWRConfiguration } from "swr"; import type { AxiosRequestConfig } from "axios"; -import { get, patch, post } from "@/Utils/ApiClient"; +import { get, patch, post, deleteOp } from "@/Utils/ApiClient"; import { useState } from "react"; import { useToast } from "@/Hooks/UseToast"; @@ -107,3 +107,38 @@ export const usePatch = () => { return { patch: patchFn, loading, error }; }; + +export const useDelete = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { toastError, toastSuccess } = useToast(); + + const deleteFn = async ( + endpoint: string, + config?: AxiosRequestConfig + ): Promise | null> => { + setLoading(true); + setError(null); + + try { + const res = await deleteOp>(endpoint, { + ...config, + headers: { + ...config?.headers, + }, + }); + toastSuccess(res.data?.msg || "Operation successful"); + return res.data; + } catch (err: any) { + console.error(err); + const errMsg = err?.response?.data?.message || err.message || "An error occurred"; + toastError(errMsg); + setError(errMsg); + return null; + } finally { + setLoading(false); + } + }; + + return { deleteFn, loading, error }; +}; diff --git a/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx new file mode 100644 index 000000000..f38e1c706 --- /dev/null +++ b/client/src/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable.tsx @@ -0,0 +1,254 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Table, Pagination, StatusLabel } from "@/Components/v2/design-elements"; +import { HistogramPageSpeed } from "@/Components/v2/monitors"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import type { Header } from "@/Components/v2/design-elements/Table"; +import { ActionsMenu } from "@/Components/v2/actions-menu"; + +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; +import { usePatch } from "@/Hooks/UseApi"; + +import type { Monitor, MonitorWithChecks } from "@/Types/Monitor"; +import type { ActionMenuItem } from "@/Components/v2/actions-menu"; + +export const PageSpeedMonitorsTable = ({ + monitors, + refetch, + setSelectedMonitor, + sortField, + setSortField, + sortOrder, + setSortOrder, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + monitors: MonitorWithChecks[]; + refetch: Function; + setSelectedMonitor: Function; + sortField: string; + setSortField: (field: string) => void; + sortOrder: "asc" | "desc"; + setSortOrder: (order: "asc" | "desc") => void; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const navigate = useNavigate(); + const { + patch, + // loading: isPatching, + // error: postError, + } = usePatch(); + + const handlePageChange = ( + _e: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = ( + e: React.ChangeEvent + ) => { + const value = Number(e.target.value); + setPage(0); + setRowsPerPage(value); + }; + + 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 getActions = (monitor: Monitor): ActionMenuItem[] => { + return [ + { + id: 1, + label: t("pages.common.monitors.actions.openSite"), + action: () => { + window.open(monitor.url, "_blank", "noreferrer"); + }, + closeMenu: true, + }, + { + id: 2, + label: t("pages.common.monitors.actions.details"), + action: () => { + navigate(`${monitor.id}`); + }, + }, + { + id: 3, + label: t("pages.common.monitors.actions.incidents"), + action: () => { + navigate(`/incidents?monitorId=${monitor.id}`); + }, + }, + { + id: 4, + label: t("pages.common.monitors.actions.configure"), + action: () => { + navigate(`/pagespeed/configure/${monitor.id}`); + }, + }, + // { + // id: 5, + // label: "Clone", + // action: () => { + + // }, + // }, + { + id: 6, + label: + monitor.isActive === false + ? t("common.buttons.resume") + : t("common.buttons.pause"), + action: async () => { + await patch(`/monitors/${monitor.id}/active`); + refetch(); + }, + closeMenu: true, + }, + { + id: 7, + label: ( + + {t("common.buttons.delete")} + + ), + action: () => { + setSelectedMonitor(monitor); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = () => { + const renderSortIcon = (isActive: boolean) => ( + + {isActive ? ( + sortOrder === "asc" ? ( + + ) : ( + + ) + ) : null} + + ); + + const headers: Header[] = [ + { + id: "name", + content: ( + handleSort(e, "name")} + sx={{ cursor: "pointer" }} + > + {t("common.table.headers.name")} + {renderSortIcon(sortField === "name")} + + ), + render: (row) => { + return row.name; + }, + }, + { + id: "status", + content: ( + handleSort(e, "status")} + sx={{ cursor: "pointer" }} + > + {t("common.table.headers.status")} + {renderSortIcon(sortField === "status")} + + ), + render: (row) => { + return ; + }, + }, + { + id: "histogram", + content: t("pages.pageSpeed.table.headers.pageSpeedScore"), + render: (row) => { + return ( + + + + ); + }, + }, + + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + let headers = getHeaders(); + + if (isSmall) { + headers = headers.filter((h) => h.id !== "histogram"); + } + return ( + +
{ + navigate(`/pagespeed/${row.id}`); + }} + /> + + + ); +}; diff --git a/client/src/Pages/PageSpeed/Monitors/index.tsx b/client/src/Pages/PageSpeed/Monitors/index.tsx new file mode 100644 index 000000000..887ec02c0 --- /dev/null +++ b/client/src/Pages/PageSpeed/Monitors/index.tsx @@ -0,0 +1,117 @@ +import { + MonitorBasePageWithStates, + UpStatusBox, + DownStatusBox, + PausedStatusBox, + PageSpeedKeyPriorityFallback, +} from "@/Components/v2/design-elements"; +import { Dialog } from "@/Components/v2/inputs"; +import { HeaderCreate } from "@/Components/v2/common"; +import Stack from "@mui/material/Stack"; +import { PageSpeedMonitorsTable } from "@/Pages/PageSpeed/Monitors/Components/PageSpeedMonitorsTable"; +import type { Monitor } from "@/Types/Monitor"; + +import { useTheme } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { useIsAdmin } from "@/Hooks/useIsAdmin"; +import { useGet, useDelete } from "@/Hooks/UseApi"; +import type { MonitorsWithChecksResponse } from "@/Types/Monitor"; +import type { AppSettingsResponse } from "@/Types/Settings"; + +const PageSpeedMonitorsPage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const isAdmin = useIsAdmin(); + const { deleteFn, loading: isDeleting } = useDelete(); + + const monitorsUrl = "/monitors/team/with-checks?type=pagespeed&limit=10"; + const { + data: monitorsData, + isLoading: monitorsIsLoading, + error: monitorsError, + refetch, + } = useGet(monitorsUrl, {}, { refreshInterval: 30000 }); + + const settingsUrl = "/settings"; + const { + data: settingsData, + isLoading: settingsIsLoading, + error: settingsError, + } = useGet(settingsUrl); + + 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 monitors = monitorsData?.monitors; + const monitorsCount = monitorsData?.count; + const summary = monitorsData?.summary; + + const isLoading = monitorsIsLoading || settingsIsLoading; + + const showApiKeyWarning = isAdmin && settingsData && !settingsData.pagespeedKeySet; + + const handleConfirm = async () => { + if (!selectedMonitor) return; + await deleteFn(`/monitors/${selectedMonitor.id}`); + setSelectedMonitor(null); + refetch(); + }; + + const handleCancel = () => { + setSelectedMonitor(null); + }; + + return ( + : undefined} + > + + + + + + + + + + ); +}; + +export default PageSpeedMonitorsPage; diff --git a/client/src/Pages/PageSpeed/Monitors/index.jsx b/client/src/Pages/PageSpeed/Monitors/old.jsx similarity index 100% rename from client/src/Pages/PageSpeed/Monitors/index.jsx rename to client/src/Pages/PageSpeed/Monitors/old.jsx diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx index 21e014688..c7f5c4c7f 100644 --- a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -24,6 +24,7 @@ import type { RootState } from "@/Types/state"; export const MonitorTable = ({ monitors, refetch, + setSelectedMonitor, sortField, setSortField, sortOrder, @@ -36,6 +37,7 @@ export const MonitorTable = ({ }: { monitors: Monitor[]; refetch: Function; + setSelectedMonitor: (monitor: Monitor | null) => void; sortField: string; setSortField: (field: string) => void; sortOrder: "asc" | "desc"; @@ -97,7 +99,7 @@ export const MonitorTable = ({ id: 4, label: t("pages.common.monitors.actions.configure"), action: () => { - navigate(`/uptime/create/${monitor.id}`); + navigate(`/uptime/configure/${monitor.id}`); }, }, // { @@ -126,9 +128,7 @@ export const MonitorTable = ({ {t("pages.common.monitors.actions.delete")} ), - action: () => { - // setSelectedMonitor(monitor); - }, + action: () => setSelectedMonitor(monitor), closeMenu: true, }, ]; diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index e3e90429a..5c3828d07 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -5,14 +5,14 @@ import { DownStatusBox, PausedStatusBox, } from "@/Components/v2/design-elements"; -import { TextField } from "@/Components/v2/inputs"; +import { TextField, Dialog } from "@/Components/v2/inputs"; import Stack from "@mui/material/Stack"; import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; import { HeaderCreate } from "@/Components/v2/common"; import { useTranslation } from "react-i18next"; import useMediaQuery from "@mui/material/useMediaQuery"; -import { useGet } from "@/Hooks/UseApi"; +import { useGet, useDelete } from "@/Hooks/UseApi"; import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor"; import { useState, useMemo, useCallback } from "react"; import { useSelector, useDispatch } from "react-redux"; @@ -40,6 +40,8 @@ const UptimeMonitorsPage = () => { const [search, setSearch] = useState(""); const [sortField, setSortField] = useState(""); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); + const [selectedMonitor, setSelectedMonitor] = useState(null); + const isDialogOpen = Boolean(selectedMonitor); // Convert filter selections to API filter values // Status: "up" -> true, "down" -> false @@ -88,6 +90,7 @@ const UptimeMonitorsPage = () => { data: monitors, isLoading: monitorsLoading, error, + refetch: refetchMonitorsAndSummary, } = useGet( "/monitors/team?type=http&type=ping&type=port&type=docker", {}, @@ -107,6 +110,9 @@ const UptimeMonitorsPage = () => { const { monitors: monitorsWithChecks, summary, count } = monitorsWithChecksData ?? {}; + // Delete hook + const { deleteFn, loading: isDeleting } = useDelete(); + // Handlers const handleClearFilters = useCallback(() => { setSelectedTypes([]); @@ -115,6 +121,18 @@ const UptimeMonitorsPage = () => { setSearch(""); }, []); + const handleConfirm = async () => { + if (!selectedMonitor) return; + await deleteFn(`/monitors/${selectedMonitor.id}`); + setSelectedMonitor(null); + refetch(); + refetchMonitorsAndSummary(); + }; + + const handleCancel = () => { + setSelectedMonitor(null); + }; + const isLoading = monitorsLoading || monitorsWithChecksLoading; return ( @@ -164,6 +182,7 @@ const UptimeMonitorsPage = () => { { sortOrder={sortOrder} setSortOrder={setSortOrder} setRowsPerPage={(rowsPerPage: number) => { - console.log("wtf"); dispatch( setRowsPerPage({ value: rowsPerPage, @@ -183,6 +201,14 @@ const UptimeMonitorsPage = () => { setPage(0); }} /> + ); }; diff --git a/client/src/Routes/index.jsx b/client/src/Routes/index.jsx index 715d4374e..c9a1084d6 100644 --- a/client/src/Routes/index.jsx +++ b/client/src/Routes/index.jsx @@ -20,7 +20,7 @@ import UptimeDetails from "../Pages/Uptime/Details"; import UptimeCreate from "../Pages/Uptime/Create/index.jsx"; // PageSpeed -import PageSpeed from "../Pages/PageSpeed/Monitors/index.jsx"; +import PageSpeed from "../Pages/PageSpeed/Monitors/index"; import PageSpeedDetails from "../Pages/PageSpeed/Details/index.jsx"; import PageSpeedCreate from "../Pages/PageSpeed/Create/index.jsx"; @@ -63,6 +63,7 @@ import Logs from "../Pages/Logs/index.jsx"; const Routes = () => { const mode = useSelector((state) => state.ui.mode); const AdminCheckedRegister = withAdminCheck(AuthRegister); + const v2theme = mode === "light" ? lightTheme : darkTheme; return ( { path="/uptime" element={ <> - + @@ -105,7 +106,7 @@ const Routes = () => { path="/uptime/:monitorId/" element={ <> - + @@ -118,7 +119,13 @@ const Routes = () => { } + element={ + <> + + + + + } /> Settings to add one. Without it, the PageSpeed monitor won't function." + } + }, + "dialogs": { + "delete": { + "description": "This action cannot be undone.", + "title": "Are you sure you want to delete this?" + } }, "toasts": { "networkError": "Network error", @@ -216,6 +232,13 @@ "statusCode": "Status code" } } + }, + "pageSpeed": { + "table": { + "headers": { + "pageSpeedScore": "PageSpeed score" + } + } } }, "distributedUptimeStatusCreateStatusPage": "status page", diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 513fc2a82..38fcaef78 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -342,7 +342,7 @@ export class MonitorService implements IMonitorService { order?: "asc" | "desc"; explain?: boolean; }): Promise => { - const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId); + const summary = await this.monitorsRepository.findMonitorsSummaryByTeamId(teamId, { type }); const count = await this.monitorsRepository.findMonitorCountByTeamIdAndType(teamId, { type, filter }); const monitors = await this.monitorsRepository.findByTeamId(teamId, { limit, @@ -402,9 +402,27 @@ export class MonitorService implements IMonitorService { deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.deleteById(monitorId, teamId); - await this.monitorStatsRepository.deleteByMonitorId(monitor.id); - await this.checksRepository.deleteByMonitorId(monitor.id); - await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id); + await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error deleting monitor stats for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); + await this.checksRepository.deleteByMonitorId(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error deleting checks for monitor ${monitor.id} with name ${monitor.name}`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); + await this.statusPagesRepository.removeMonitorFromStatusPages(monitor.id).catch((err: any) => { + this.logger.warn({ + message: `Error removing monitor ${monitor.id} with name ${monitor.name} from status pages`, + service: SERVICE_NAME, + stack: err.stack, + }); + }); await this.jobQueue.deleteJob(monitor); return monitor; };