diff --git a/client/src/components/design-elements/LoadingSpinner.tsx b/client/src/components/design-elements/LoadingSpinner.tsx new file mode 100644 index 000000000..840c37014 --- /dev/null +++ b/client/src/components/design-elements/LoadingSpinner.tsx @@ -0,0 +1,12 @@ +import CircularProgress, { type CircularProgressProps } from "@mui/material/CircularProgress"; + +type LoadingSpinnerProps = { show?: boolean } & CircularProgressProps; + +export const LoadingSpinner = ({ + show = true, + size = 18, + thickness = 5, + ...props +}: LoadingSpinnerProps) => (show ? ( + +) : null); diff --git a/client/src/components/design-elements/index.tsx b/client/src/components/design-elements/index.tsx index 66bbf35cd..963dd9c44 100644 --- a/client/src/components/design-elements/index.tsx +++ b/client/src/components/design-elements/index.tsx @@ -30,3 +30,4 @@ export { Breadcrumb } from "./Breadcrumb"; export { InfoBox } from "./InfoBox"; export { Tooltip, TooltipWithInfo } from "./Tooltip"; export { PlanCard } from "./PlanCard"; +export { LoadingSpinner } from "./LoadingSpinner"; diff --git a/client/src/components/layouts/sidebar/Menu.tsx b/client/src/components/layouts/sidebar/Menu.tsx index f8378faf1..71e81b226 100644 --- a/client/src/components/layouts/sidebar/Menu.tsx +++ b/client/src/components/layouts/sidebar/Menu.tsx @@ -5,6 +5,7 @@ import { Server, AlertTriangle, LayoutList, + ListChecks, Wrench, Activity, LifeBuoy, @@ -45,6 +46,11 @@ export const getMenu = (t: Function, user: IUser) => { path: "incidents", icon: , }, + { + name: t("menu.checks"), + path: "checks", + icon: , + }, { name: t("menu.statusPages"), diff --git a/client/src/components/monitors/HeaderFilter.tsx b/client/src/components/monitors/HeaderFilter.tsx index 45a9a9401..ca02ba1df 100644 --- a/client/src/components/monitors/HeaderFilter.tsx +++ b/client/src/components/monitors/HeaderFilter.tsx @@ -3,6 +3,7 @@ import Box from "@mui/material/Box"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import { Select, Checkbox, TextInput } from "@/components/inputs"; +import { LoadingSpinner } from "@/components/design-elements"; import { useEffect, useState } from "react"; import { useTheme } from "@mui/material/styles"; @@ -21,6 +22,7 @@ type HeaderFilterProps = { onStatusesChange: (statuses: MonitorStatus[]) => void; searchString: string; onSearchStringChange?: (value: string) => void; + loading?: boolean; }; const coerceValue = (value: string | string[]): T[] => { @@ -37,6 +39,7 @@ export const HeaderFilter = ({ onStatusesChange, searchString, onSearchStringChange, + loading = false, }: HeaderFilterProps) => { const theme = useTheme(); const [localSearch, setLocalSearch] = useState(searchString); @@ -65,7 +68,6 @@ export const HeaderFilter = ({ return ( ))} + { setLocalSearch(event.target.value); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 12b5e465f..c62a84e65 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -635,7 +635,8 @@ "statusPages": "Status pages", "support": "Support", "team": "Team", - "uptime": "Uptime" + "uptime": "Uptime", + "checks": "Checks" }, "message": "Message", "monitor": "monitor", diff --git a/client/src/pages/checks/ChecksPage.tsx b/client/src/pages/checks/ChecksPage.tsx new file mode 100644 index 000000000..0fd82f430 --- /dev/null +++ b/client/src/pages/checks/ChecksPage.tsx @@ -0,0 +1,113 @@ +import { BasePage, InfoBox } from "@/components/design-elements"; +import Stack from "@mui/material/Stack"; +import MenuItem from "@mui/material/MenuItem"; +import { Select } from "@/components/inputs"; +import { HeaderRange } from "@/components/common/HeaderRange"; +import { ChecksTable } from "@/pages/checks/ChecksTable"; +import { LoadingSpinner } from "@/components/design-elements"; + +import { useTheme } from "@mui/material/styles"; +import { useState } from "react"; +import type { SelectChangeEvent } from "@mui/material/Select"; +import { useGet } from "@/hooks/UseApi"; +import type { ApiResponse } from "@/hooks/UseApi"; +import type { IMonitor } from "@/types/monitor"; +import type { ICheck } from "@/types/check"; + +const ChecksPage = () => { + const theme = useTheme(); + const [selectedMonitorId, setSelectedMonitorId] = useState("all"); + const [range, setRange] = useState("2h"); + const [status, setStatus] = useState<"up" | "down">("down"); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + const { + response: monitorResponse, + loading: monitorsLoading, + isValidating: monitorsValidating, + } = useGet>( + `/monitors`, + {}, + { keepPreviousData: true } + ); + const monitors: IMonitor[] = monitorResponse?.data || []; + + const { + response: checksResponse, + loading: checksLoading, + isValidating: checksValidating, + } = useGet>( + `/checks?page=${page}&rowsPerPage=${rowsPerPage}&range=${range}&status=${status}${ + selectedMonitorId !== "all" ? `&monitorId=${selectedMonitorId}` : "" + }`, + {}, + { keepPreviousData: true }, + { useTeamIdAsKey: true } + ); + + const checks = checksResponse?.data?.checks || []; + const checksCount = checksResponse?.data?.count || 0; + + return ( + + + + + + onChange={(e: SelectChangeEvent) => { + setSelectedMonitorId(e.target.value); + }} + value={selectedMonitorId} + > + All monitors + {monitors.map((monitor) => ( + + {monitor.name} + + ))} + + + onChange={(e: SelectChangeEvent) => { + const val = e.target.value as "up" | "down"; + setStatus(val); + }} + value={status} + > + Up + Down + + + + + + + + ); +}; + +export default ChecksPage; diff --git a/client/src/pages/checks/ChecksTable.tsx b/client/src/pages/checks/ChecksTable.tsx new file mode 100644 index 000000000..3f4d1e367 --- /dev/null +++ b/client/src/pages/checks/ChecksTable.tsx @@ -0,0 +1,122 @@ +import { + Table, + Pagination, + ValueLabel, + StatusLabel, +} from "@/components/design-elements"; +import Box from "@mui/material/Box"; +import type { Header } from "@/components/design-elements/Table"; + +import { useTranslation } from "react-i18next"; +import { formatDateWithTz } from "@/utils/TimeUtils"; +import { useAppSelector } from "@/hooks/AppHooks"; +import type { ICheck } from "@/types/check"; +import type { MonitorStatus } from "@/types/monitor"; + +type CheckWithMonitor = ICheck & { + metadata?: { monitorId?: { name?: string } }; +}; + +export const ChecksTable = ({ + checks, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + checks: CheckWithMonitor[]; + count: number; + page: number; + setPage: (page: number) => void; + rowsPerPage: number; + setRowsPerPage: (rowsPerPage: number) => void; +}) => { + const { t } = useTranslation(); + const uiTimezone = useAppSelector((state: any) => state.ui.timezone); + + const getHeaders = (t: Function, uiTimezone: string) => { + const headers: Header[] = [ + { + id: "monitorName", + content: "Monitor name", + render: (row) => { + return row.metadata?.monitorId?.name || "N/A"; + }, + }, + { + id: "status", + content: "Status", + render: (row) => { + return ; + }, + }, + { + id: "date", + content: "Date and time", + render: (row) => { + return formatDateWithTz( + row.createdAt, + "ddd, MMMM D, YYYY, HH:mm A", + uiTimezone + ); + }, + }, + { + id: "statusCode", + content: "Status code", + render: (row) => { + const code = row.httpStatusCode; + const value = + code < 300 ? "positive" : code < 400 ? "neutral" : "negative"; + return ; + }, + }, + { + id: "message", + content: "Message", + render: (row) => { + return row.message || "N/A"; + }, + }, + ]; + return headers; + }; + + const headers = getHeaders(t, uiTimezone); + + 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); + }; + + return ( + + { + alert("not yet"); + }} + /> + + + ); +}; diff --git a/client/src/pages/incidents/IncidentsPage.tsx b/client/src/pages/incidents/IncidentsPage.tsx index 595647b8a..963078e01 100644 --- a/client/src/pages/incidents/IncidentsPage.tsx +++ b/client/src/pages/incidents/IncidentsPage.tsx @@ -1,6 +1,7 @@ import Stack from "@mui/material/Stack"; import MenuItem from "@mui/material/MenuItem"; import { Select } from "@/components/inputs"; +import { LoadingSpinner } from "@/components/design-elements"; import { BasePage, InfoBox } from "@/components/design-elements"; import { IncidentTable } from "@/pages/incidents/IncidentTable"; import { HeaderRange } from "@/components/common/HeaderRange"; @@ -24,9 +25,12 @@ const IncidentsPage = () => { const [range, setRange] = useState("all"); const [resolved, setResolved] = useState(false); - const { response, loading, refetch } = useGet< - ApiResponse<{ count: number; incidents: IIncident[] }> - >( + const { + response, + loading: incidentsLoading, + isValidating: incidentsValidating, + refetch, + } = useGet>( `/incidents?page=${page}&rowsPerPage=${rowsPerPage}&range=${range}${ selectedMonitorId && selectedMonitorId !== "all" ? `&monitorId=${selectedMonitorId}` @@ -42,7 +46,11 @@ const IncidentsPage = () => { : []; const count = response?.data?.count || 0; - const { response: monitorResponse } = useGet>( + const { + response: monitorResponse, + loading: monitorsLoading, + isValidating: monitorsValidating, + } = useGet>( `/monitors`, {}, { keepPreviousData: true } @@ -103,11 +111,15 @@ const IncidentsPage = () => { Resolved Unresolved + diff --git a/client/src/pages/uptime/UptimeMonitors.tsx b/client/src/pages/uptime/UptimeMonitors.tsx index 12c2310cc..f72cfdb2e 100644 --- a/client/src/pages/uptime/UptimeMonitors.tsx +++ b/client/src/pages/uptime/UptimeMonitors.tsx @@ -4,6 +4,7 @@ import { DownStatusBox, PausedStatusBox, InfoBox, + LoadingSpinner, } from "@/components/design-elements"; import { HeaderFilter } from "@/components/monitors"; import { HeaderCreate } from "@/components/common"; @@ -137,6 +138,7 @@ const UptimeMonitors = () => { entitlement="monitorsMax" entitlementCount={count} /> + @@ -152,6 +154,7 @@ const UptimeMonitors = () => { onStatusesChange={setSelectedStatuses} searchString={searchString} onSearchStringChange={setSearchString} + loading={isValidating} /> { /> } /> } /> + } /> } />