From a0a8ceb0db06dcdb47edceb8027fdcea50f88ef3 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 23 Jan 2026 23:54:58 +0000 Subject: [PATCH] uptime --- .../src/Components/v2/actions-menu/index.tsx | 56 ++ .../v2/common/HeatmapResponseTime.tsx | 140 +++++ .../v2/common/HeatmapResponseTimeTooltip.tsx | 71 +++ .../v2/common/HistogramResponseTime.tsx | 102 ++++ client/src/Components/v2/common/index.ts | 3 + .../Components/v2/design-elements/BaseBox.tsx | 19 + .../v2/design-elements/StatusBox.tsx | 112 ++++ .../v2/design-elements/StatusLabel.tsx | 101 +++ .../Components/v2/design-elements/Table.tsx | 576 ++++++++++++++++++ .../Components/v2/design-elements/index.tsx | 5 + .../src/Components/v2/inputs/AutoComplete.tsx | 88 +++ client/src/Components/v2/inputs/TextInput.tsx | 56 ++ client/src/Components/v2/inputs/index.tsx | 2 + .../Components/v2/monitors/FilterControls.tsx | 8 +- client/src/Features/UI/uiSlice.js | 5 + client/src/Hooks/UseApi.ts | 77 ++- client/src/Hooks/UseToast.ts | 23 + .../Components/UptimeMonitorsTable.tsx | 276 +++++++++ client/src/Pages/Uptime/Monitors/index.tsx | 152 ++++- client/src/Types/Check.ts | 148 ++++- client/src/Types/Monitor.ts | 11 +- client/src/Types/mui.d.ts | 22 +- client/src/Types/state.ts | 1 + client/src/Utils/DataUtils.ts | 129 ++++ client/src/Utils/MonitorUtils.ts | 120 ++++ client/src/Utils/TimeUtils.ts | 52 ++ client/src/locales/en.json | 65 +- 27 files changed, 2326 insertions(+), 94 deletions(-) create mode 100644 client/src/Components/v2/actions-menu/index.tsx create mode 100644 client/src/Components/v2/common/HeatmapResponseTime.tsx create mode 100644 client/src/Components/v2/common/HeatmapResponseTimeTooltip.tsx create mode 100644 client/src/Components/v2/common/HistogramResponseTime.tsx create mode 100644 client/src/Components/v2/common/index.ts create mode 100644 client/src/Components/v2/design-elements/BaseBox.tsx create mode 100644 client/src/Components/v2/design-elements/StatusBox.tsx create mode 100644 client/src/Components/v2/design-elements/StatusLabel.tsx create mode 100644 client/src/Components/v2/design-elements/Table.tsx create mode 100644 client/src/Components/v2/inputs/AutoComplete.tsx create mode 100644 client/src/Components/v2/inputs/TextInput.tsx create mode 100644 client/src/Hooks/UseToast.ts create mode 100644 client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx create mode 100644 client/src/Utils/DataUtils.ts create mode 100644 client/src/Utils/MonitorUtils.ts create mode 100644 client/src/Utils/TimeUtils.ts diff --git a/client/src/Components/v2/actions-menu/index.tsx b/client/src/Components/v2/actions-menu/index.tsx new file mode 100644 index 000000000..3b2f3688a --- /dev/null +++ b/client/src/Components/v2/actions-menu/index.tsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import IconButton from "@mui/material/IconButton"; +import { Settings } from "lucide-react"; + +export type ActionMenuItem = { + id: number | string; + label: React.ReactNode; + action: Function; + closeMenu?: boolean; +}; + +export const ActionsMenu = ({ items }: { items: ActionMenuItem[] }) => { + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setAnchorEl(e.currentTarget); + }; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + setAnchorEl(null); + }; + + return ( +
+ + + + + {items.map((item) => ( + ) => { + e.stopPropagation(); + if (item.closeMenu) handleClose(e); + item.action(); + }} + > + {item.label} + + ))} + +
+ ); +}; diff --git a/client/src/Components/v2/common/HeatmapResponseTime.tsx b/client/src/Components/v2/common/HeatmapResponseTime.tsx new file mode 100644 index 000000000..e81a2701e --- /dev/null +++ b/client/src/Components/v2/common/HeatmapResponseTime.tsx @@ -0,0 +1,140 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import type { Check } from "@/Types/Check"; +import { getResponseColor } from "@/Utils/DataUtils"; +import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; +import type { SxProps } from "@mui/material/styles"; +import type { ResponsiveStyleValue } from "@mui/system"; + +interface PlaceholderCheck { + status: "placeholder"; + responseTime: 0; + createdAt: ""; +} + +interface HeatmapResponseTimeProps { + checks: Check[]; + gap?: ResponsiveStyleValue; + availabilityCellSx?: SxProps; + responseCellSx?: SxProps; +} + +export const HeatmapResponseTime = ({ + checks, + gap, + availabilityCellSx, + responseCellSx, +}: HeatmapResponseTimeProps) => { + const theme = useTheme(); + if (!gap) { + gap = theme.spacing(0.5); + } + + if (!checks || checks.length === 0) return null; + + const latestChecks = checks.slice(-25).reverse(); + + let data: Array; + if (latestChecks.length !== 25) { + const placeholders = Array(25 - latestChecks.length).fill({ + status: "placeholder" as const, + }); + data = [...latestChecks, ...placeholders]; + } else { + data = latestChecks; + } + return ( + + + {data.map((check, index) => { + const statusBg = + check.status === "placeholder" + ? theme.palette.action.hover + : check.status === true + ? theme.palette.success.light + : theme.palette.error.light; + const respBg = + check.status === "placeholder" + ? theme.palette.action.hover + : getResponseColor(check.responseTime || 0, { + start: theme.palette.success.main, + mid: theme.palette.warning.main, + end: theme.palette.error.main, + }); + const statusBorder = + check.status === "placeholder" + ? theme.palette.divider + : check.status === true + ? theme.palette.success.main + : theme.palette.error.main; + + return ( + + + + + + + + + + + + ); + })} + + + ); +}; diff --git a/client/src/Components/v2/common/HeatmapResponseTimeTooltip.tsx b/client/src/Components/v2/common/HeatmapResponseTimeTooltip.tsx new file mode 100644 index 000000000..448a60e49 --- /dev/null +++ b/client/src/Components/v2/common/HeatmapResponseTimeTooltip.tsx @@ -0,0 +1,71 @@ +import Stack from "@mui/material/Stack"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; +import { formatDateWithTz } from "@/Utils/TimeUtils"; +import type { Check } from "@/Types/Check"; +import { useTheme } from "@mui/material/styles"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/Types/state"; + +type HeatmapCheck = Check | { status: "placeholder"; responseTime: 0; createdAt: "" }; + +export const HeatmapResponseTimeTooltip = ({ + children, + check, +}: { + children: React.ReactElement; + check: HeatmapCheck; +}) => { + const uiTimezone = useSelector((state: RootState) => state.ui.timezone); + const theme = useTheme(); + + const getColor = (status: boolean) => { + if (status === true) return theme.palette.success.light; + if (status === false) return theme.palette.error.light; + }; + + if (check.status === "placeholder") { + return children; + } + + return ( + + + {formatDateWithTz(check?.createdAt, "ddd, MMMM D, YYYY, HH:mm A", uiTimezone)} + + {check?.originalResponseTime && ( + + Response Time: {check.originalResponseTime.toFixed()} ms + + )} + + + Status:{" "} + {check?.status} + + + } + > + {children} + + ); +}; diff --git a/client/src/Components/v2/common/HistogramResponseTime.tsx b/client/src/Components/v2/common/HistogramResponseTime.tsx new file mode 100644 index 000000000..bbe3bd9d4 --- /dev/null +++ b/client/src/Components/v2/common/HistogramResponseTime.tsx @@ -0,0 +1,102 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import type { ResponsiveStyleValue } from "@mui/system"; +import type { Check } from "@/Types/Check"; +import { HeatmapResponseTimeTooltip } from "./HeatmapResponseTimeTooltip"; + +interface HistogramResponseTimeProps { + checks: Check[]; + height?: ResponsiveStyleValue; + gap?: ResponsiveStyleValue; +} + +const DEFAULT_HEIGHT = 50; + +export const HistogramResponseTime = ({ + checks, + height = DEFAULT_HEIGHT, + gap, +}: HistogramResponseTimeProps) => { + const theme = useTheme(); + + if (!Array.isArray(checks) || checks.length === 0) return null; + + let data = Array(); + + if (!checks || checks.length === 0) { + data = []; + } + if (checks.length !== 25) { + const placeholders = Array(25 - checks.length).fill({ + status: "placeholder", + }); + data = [...checks, ...placeholders]; + } else { + data = checks; + } + const chartHeight = typeof height === "number" ? `${height}px` : height; + const gridGap = gap ?? theme.spacing(0.5); + return ( + + + {data.map((check, index) => { + const isPlaceholder = (check as any).status === "placeholder"; + const heightPct = `${Math.max(0, Math.min(100, (check as any).responseTime ?? 0))}%`; + const barColor = + check.status === true + ? theme.palette.success.light + : theme.palette.error.light; + const bar = ( + + + + ); + + return isPlaceholder ? ( + + {bar} + + ) : ( + + {bar} + + ); + })} + + + ); +}; diff --git a/client/src/Components/v2/common/index.ts b/client/src/Components/v2/common/index.ts new file mode 100644 index 000000000..698d25d1e --- /dev/null +++ b/client/src/Components/v2/common/index.ts @@ -0,0 +1,3 @@ +export * from "./HistogramResponseTime"; +export * from "./HeatmapResponseTime"; +export * from "./HeatmapResponseTimeTooltip"; diff --git a/client/src/Components/v2/design-elements/BaseBox.tsx b/client/src/Components/v2/design-elements/BaseBox.tsx new file mode 100644 index 000000000..580d15dcf --- /dev/null +++ b/client/src/Components/v2/design-elements/BaseBox.tsx @@ -0,0 +1,19 @@ +import Box, { type BoxProps } from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; + +export const BaseBox = ({ sx, ...rest }: BoxProps) => { + const theme = useTheme(); + return ( + + ); +}; diff --git a/client/src/Components/v2/design-elements/StatusBox.tsx b/client/src/Components/v2/design-elements/StatusBox.tsx new file mode 100644 index 000000000..6b1c1920c --- /dev/null +++ b/client/src/Components/v2/design-elements/StatusBox.tsx @@ -0,0 +1,112 @@ +import Stack from "@mui/material/Stack"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import { BaseBox } from "@/Components/v2/design-elements"; +import Background from "@/assets/Images/background-grid.svg?react"; +import { useTranslation } from "react-i18next"; + +import { useTheme } from "@mui/material/styles"; + +type StatusBoxProps = React.PropsWithChildren<{ children: React.ReactNode }>; + +export const BGBox = ({ children }: StatusBoxProps) => { + const theme = useTheme(); + return ( + + + + + {children} + + ); +}; + +const StatusBox = ({ + label, + n, + color, +}: { + label: string; + n: number; + color: string | undefined; +}) => { + const theme = useTheme(); + return ( + + + + {label} + + + {n} + + + + ); +}; + +export const UpStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; + +export const DownStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; + +export const PausedStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; + +export const InitializingStatusBox = ({ n }: { n: number }) => { + const theme = useTheme(); + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/client/src/Components/v2/design-elements/StatusLabel.tsx b/client/src/Components/v2/design-elements/StatusLabel.tsx new file mode 100644 index 000000000..30adf5917 --- /dev/null +++ b/client/src/Components/v2/design-elements/StatusLabel.tsx @@ -0,0 +1,101 @@ +import Box from "@mui/material/Box"; +import { BaseBox } from "@/Components/v2/design-elements"; +import Typography from "@mui/material/Typography"; + +import type { MonitorStatus } from "@/Types/Monitor"; +import type { SxProps } from "@mui/material/styles"; +import { getStatusPalette, getValuePalette } from "@/Utils/MonitorUtils"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; + +export const ValueTypes = ["positive", "negative", "neutral"] as const; +export type ValueType = (typeof ValueTypes)[number]; + +export const StatusLabel = ({ + status, + isActive, + sx, +}: { + status: MonitorStatus; + isActive: boolean; + sx?: SxProps; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + const palette = getStatusPalette(status); + + const determineStatus = (isActive: boolean, status: MonitorStatus): string => { + if (isActive === false) { + return t("pages.common.monitors.status.paused"); + } + + if (status === true) { + return t("pages.common.monitors.status.up"); + } + if (status === false) { + return t("pages.common.monitors.status.down"); + } + return t("pages.common.monitors.status.initializing"); + }; + + return ( + + + + {determineStatus(isActive, status)} + + + ); +}; + +export const ValueLabel = ({ value, text }: { value: ValueType; text: string }) => { + const theme = useTheme(); + const palette = getValuePalette(value); + const transformedText = text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); + + return ( + + + {transformedText} + + ); +}; diff --git a/client/src/Components/v2/design-elements/Table.tsx b/client/src/Components/v2/design-elements/Table.tsx new file mode 100644 index 000000000..39f465b79 --- /dev/null +++ b/client/src/Components/v2/design-elements/Table.tsx @@ -0,0 +1,576 @@ +import Stack from "@mui/material/Stack"; +import Grid2 from "@mui/material/Grid2"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import Paper from "@mui/material/Paper"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TableRow from "@mui/material/TableRow"; +import Collapse from "@mui/material/Collapse"; +import Tooltip from "@mui/material/Tooltip"; + +import IconButton from "@mui/material/IconButton"; +import { + ChevronsLeft, + ChevronsRight, + ChevronLeft, + ChevronRight, + Coffee, + Ellipsis, + PartyPopper, +} from "lucide-react"; + +import TablePagination from "@mui/material/TablePagination"; +import type { TablePaginationProps, TablePaginationOwnProps } from "@mui/material/TablePagination"; + +import { useTranslation } from "react-i18next"; +import { useState, Fragment } from "react"; +import { useTheme } from "@mui/material/styles"; +import useMediaQuery from "@mui/material/useMediaQuery"; + +export type Header = { + id: number | string; + content: React.ReactNode; + onClick?: (event: React.MouseEvent, row: T) => void; + render: (row: T) => React.ReactNode; +}; + +type DataTableProps = { + headers: Header[]; + data: T[]; + onRowClick?: (row: T) => void; + cardsOnSmallScreens?: boolean; + expandableRows?: boolean; + renderExpandedContent?: (row: T) => React.ReactNode; + emptyViewText?: string; + emptyViewPositive?: boolean; +}; + +export function DataTable< + T extends { + id?: string | number; + _id?: string | number; + onRowClick?: (row: T) => void; + }, +>({ + headers, + data, + onRowClick, + cardsOnSmallScreens = true, + expandableRows = false, + renderExpandedContent, + emptyViewText, + emptyViewPositive, +}: DataTableProps) { + const theme = useTheme(); + const [expanded, setExpanded] = useState<(string | number) | null>(null); + const handleExpand = (row: T) => { + const key = row.id || row._id || null; + setExpanded(expanded === key ? null : key); + }; + + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + + if (data.length === 0 || headers.length === 0) { + return ( + + ); + } + + const keys = []; + // Return stack of cards for small screens + if (isSmall && cardsOnSmallScreens) { + return ( + + {data.map((row) => { + const key = row.id || row._id || Math.random(); + keys.push(key); + return ( + (onRowClick ? onRowClick(row) : null)} + spacing={theme.spacing(4)} + sx={{ + borderStyle: "solid", + borderWidth: 1, + borderColor: theme.palette.divider, + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(4), + cursor: onRowClick ? "pointer" : "default", + }} + key={key} + > + {headers.map((header) => { + return ( + + + + {header.content} + + + + {header.render(row)}{" "} + + + ); + })} + {expandableRows && renderExpandedContent && renderExpandedContent(row) && ( + {renderExpandedContent(row)} + )} + + ); + })} + + ); + } + + return ( + + + + + {headers.map((header, idx) => { + return ( + + {header.content} + + ); + })} + + + + {data.map((row) => { + const key = row.id || row._id || Math.random(); + const isExpanded = expanded === key; + + return ( + + { + if (expandableRows) handleExpand(row); + else if (onRowClick) onRowClick(row); + }} + > + {headers.map((header, index) => { + return ( + header.onClick!(e, row) : undefined + } + > + {header.render(row)} + + ); + })} + + {expandableRows && + renderExpandedContent && + renderExpandedContent(row) && ( + + + + {renderExpandedContent(row)} + + + + )} + + ); + })} + +
+
+ ); +} + +interface TablePaginationActionsProps { + count: number; + page: number; + rowsPerPage: number; + onPageChange: (event: React.MouseEvent, newPage: number) => void; +} + +function TablePaginationActions(props: TablePaginationActionsProps) { + const theme = useTheme(); + const { count, page, rowsPerPage, onPageChange } = props; + + const handleFirstPageButtonClick = (event: React.MouseEvent) => { + onPageChange(event, 0); + }; + + const handleBackButtonClick = (event: React.MouseEvent) => { + onPageChange(event, page - 1); + }; + + const handleNextButtonClick = (event: React.MouseEvent) => { + onPageChange(event, page + 1); + }; + + const handleLastPageButtonClick = (event: React.MouseEvent) => { + onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); + }; + + return ( + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="next page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="last page" + > + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + + ); +} + +interface HasMoreTablePaginationActionsProps { + hasMore?: boolean; + page: number; + onPageChange: (event: React.MouseEvent, newPage: number) => void; +} +function HasMoreTablePaginationActions(props: HasMoreTablePaginationActionsProps) { + const theme = useTheme(); + const { hasMore, page, onPageChange } = props; + + const handleFirstPageButtonClick = (event: React.MouseEvent) => { + onPageChange(event, 0); + }; + + const handleBackButtonClick = (event: React.MouseEvent) => { + onPageChange(event, page - 1); + }; + + const handleNextButtonClick = (event: React.MouseEvent) => { + onPageChange(event, page + 1); + }; + + return ( + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + + + + + + + ); +} + +interface PaginationProps extends TablePaginationOwnProps { + component?: React.ElementType; + hasMore?: boolean; +} + +export const Pagination = ({ ...props }: PaginationProps) => { + const { hasMore, ...rest } = props; + const isSmall = useMediaQuery((theme: any) => theme.breakpoints.down("sm")); + const theme = useTheme(); + const labelDisplayedRows = ({ + from, + to, + count, + }: { + from: number; + to: number; + count: number; + }) => { + if (hasMore) { + return to === 0 ? "" : `${from}–${to}`; + } + return `${from}–${to} of ${count}`; + }; + + return ( + + typeof hasMore === "boolean" ? ( + + ) : ( + + ) + } + rowsPerPageOptions={[5, 10, 25]} + labelDisplayedRows={labelDisplayedRows} + {...rest} + sx={{ + "& .MuiTablePagination-toolbar": isSmall + ? { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gridAutoRows: "auto", + rowGap: theme.spacing(1), + alignItems: "center", + justifyItems: "center", + paddingLeft: 0, + paddingRight: 0, + } + : { display: "flex", alignItems: "center" }, + "& .MuiTablePagination-selectLabel": { + gridColumn: "1", + gridRow: "1", + justifySelf: "center", + }, + "& .MuiTablePagination-select": { + gridColumn: "2", + gridRow: "1", + justifySelf: "center", + }, + "& .MuiTablePagination-displayedRows": isSmall + ? { gridColumn: "1 / span 2", gridRow: "2", justifySelf: "center" } + : {}, + "& .table-pagination-actions": isSmall + ? { gridColumn: "1 / span 2", gridRow: "3", justifySelf: "center" } + : {}, + "& .MuiSelect-select": { + border: 1, + borderColor: theme.palette.divider, + borderRadius: theme.shape.borderRadius, + }, + }} + /> + ); +}; + +const EmptyView = ({ text, positive }: { text?: string; positive?: boolean }) => { + const theme = useTheme(); + const { t } = useTranslation(); + const Icon = positive ? PartyPopper : Coffee; + return ( + + + + + + {text ?? t("common.table.empty")} + + + ); +}; diff --git a/client/src/Components/v2/design-elements/index.tsx b/client/src/Components/v2/design-elements/index.tsx index 9970ed980..374dabef7 100644 --- a/client/src/Components/v2/design-elements/index.tsx +++ b/client/src/Components/v2/design-elements/index.tsx @@ -3,3 +3,8 @@ export * from "./BasePage"; export * from "./Fallback"; export * from "./SkeletonCard"; export * from "./BulletPointCheck"; +export * from "./StatusBox"; +export * from "./BaseBox"; +export * from "./Table"; +export { DataTable as Table } from "./Table"; +export * from "./StatusLabel"; diff --git a/client/src/Components/v2/inputs/AutoComplete.tsx b/client/src/Components/v2/inputs/AutoComplete.tsx new file mode 100644 index 000000000..af0e8cbfd --- /dev/null +++ b/client/src/Components/v2/inputs/AutoComplete.tsx @@ -0,0 +1,88 @@ +import Autocomplete from "@mui/material/Autocomplete"; +import type { AutocompleteProps } from "@mui/material/Autocomplete"; +import { TextField, Checkbox } from "@/Components/v2/inputs"; +import ListItem from "@mui/material/ListItem"; +import Stack from "@mui/material/Stack"; +import { useTheme } from "@mui/material/styles"; +import { FieldLabel } from "./FieldLabel"; +import { ChevronDown } from "lucide-react"; + +type AutoCompleteInputProps = Omit< + AutocompleteProps, + "renderInput" +> & { + renderInput?: AutocompleteProps["renderInput"]; + fieldLabel?: string; + required?: boolean; +}; + +export const AutoCompleteInput = ({ + fieldLabel, + required, + renderInput, + ...props +}: AutoCompleteInputProps) => { + const theme = useTheme(); + const multiple = props.multiple; + + const defaultRenderInput = (params: any) => ( + + ); + + const autocomplete = ( + + } + renderInput={renderInput || defaultRenderInput} + getOptionKey={(option) => option.id} + renderTags={() => null} + renderOption={(props, option, { selected }) => { + const { key, ...optionProps } = props; + return ( + + + {multiple && } + {option.name} + + + ); + }} + sx={{ + "&.MuiAutocomplete-root .MuiAutocomplete-input": { + padding: `0 ${theme.spacing(5)}`, + }, + "& .MuiInputBase-root .MuiAutocomplete-endAdornment": { + right: theme.spacing(3), + }, + }} + /> + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {autocomplete} + + ); + } + + return autocomplete; +}; diff --git a/client/src/Components/v2/inputs/TextInput.tsx b/client/src/Components/v2/inputs/TextInput.tsx new file mode 100644 index 000000000..9a16a43c7 --- /dev/null +++ b/client/src/Components/v2/inputs/TextInput.tsx @@ -0,0 +1,56 @@ +import { forwardRef } from "react"; +import TextField from "@mui/material/TextField"; +import type { TextFieldProps } from "@mui/material"; +import { typographyLevels } from "@/Utils/Theme/v2Palette"; +import { useTheme } from "@mui/material/styles"; +import Stack from "@mui/material/Stack"; +import { FieldLabel } from "./FieldLabel"; + +interface TextInputProps extends Omit { + fieldLabel?: string; + required?: boolean; +} + +export const TextInput = forwardRef(function TextInput( + { fieldLabel, required, ...props }, + ref +) { + const theme = useTheme(); + + const input = ( + + ); + + if (fieldLabel) { + return ( + + {fieldLabel} + {input} + + ); + } + + return input; +}); +TextInput.displayName = "TextInput"; diff --git a/client/src/Components/v2/inputs/index.tsx b/client/src/Components/v2/inputs/index.tsx index 3cd2516b1..9a76f6f7f 100644 --- a/client/src/Components/v2/inputs/index.tsx +++ b/client/src/Components/v2/inputs/index.tsx @@ -2,3 +2,5 @@ export { ButtonInput as Button } from "./Button"; export * from "./FieldLabel"; export { SelectInput as Select } from "./Select"; export { CheckboxInput as Checkbox } from "./Checkbox"; +export { AutoCompleteInput as Autocomplete } from "./AutoComplete"; +export { TextInput as TextField } from "./TextInput"; diff --git a/client/src/Components/v2/monitors/FilterControls.tsx b/client/src/Components/v2/monitors/FilterControls.tsx index d5ec8cc55..5d68bf49a 100644 --- a/client/src/Components/v2/monitors/FilterControls.tsx +++ b/client/src/Components/v2/monitors/FilterControls.tsx @@ -16,6 +16,7 @@ export const FilterControls = ({ setSelectedStatus, selectedState, setSelectedState, + onClearFilters, }: { selectedTypes: MonitorType[]; setSelectedTypes: React.Dispatch>; @@ -23,6 +24,7 @@ export const FilterControls = ({ setSelectedStatus: React.Dispatch>; selectedState: string; setSelectedState: React.Dispatch>; + onClearFilters: () => void; }) => { const theme = useTheme(); const isFilterActive = @@ -78,11 +80,7 @@ export const FilterControls = ({ {isFilterActive && ( diff --git a/client/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js index af9731e9b..65aff99a8 100644 --- a/client/src/Features/UI/uiSlice.js +++ b/client/src/Features/UI/uiSlice.js @@ -29,6 +29,7 @@ const initialState = { distributedUptimeEnabled: false, language: "en", starPromptOpen: true, + chartType: "histogram", }; const uiSlice = createSlice({ @@ -73,6 +74,9 @@ const uiSlice = createSlice({ setStarPromptOpen: (state, action) => { state.starPromptOpen = action.payload; }, + setChartType: (state, action) => { + state.chartType = action.payload; + }, }, }); @@ -88,4 +92,5 @@ export const { setDistributedUptimeEnabled, setLanguage, setStarPromptOpen, + setChartType, } = uiSlice.actions; diff --git a/client/src/Hooks/UseApi.ts b/client/src/Hooks/UseApi.ts index 06845d6c1..441c2b77b 100644 --- a/client/src/Hooks/UseApi.ts +++ b/client/src/Hooks/UseApi.ts @@ -1,7 +1,9 @@ import useSWR from "swr"; import type { SWRConfiguration } from "swr"; import type { AxiosRequestConfig } from "axios"; -import { get } from "@/Utils/ApiClient"; +import { get, patch, post } from "@/Utils/ApiClient"; +import { useState } from "react"; +import { useToast } from "@/Hooks/UseToast"; interface ApiResponse { success: boolean; @@ -32,3 +34,76 @@ export const useGet = ( refetch: mutate, }; }; + +export const usePost = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { toastError, toastSuccess } = useToast(); + + const postFn = async ( + endpoint: string, + body: B, + config?: AxiosRequestConfig + ): Promise | null> => { + setLoading(true); + setError(null); + + try { + const res = await post>(endpoint, body, { + ...config, + headers: { + ...config?.headers, + }, + }); + toastSuccess(res.data?.msg || "Operation successful"); + + return res.data; + } catch (err: any) { + const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; + console.error(err); + toastError(errMsg); + setError(errMsg); + return null; + } finally { + setLoading(false); + } + }; + + return { post: postFn, loading, error }; +}; + +export const usePatch = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { toastError, toastSuccess } = useToast(); + + const patchFn = async ( + endpoint: string, + body?: B, + config?: AxiosRequestConfig + ): Promise | null> => { + setLoading(true); + setError(null); + + try { + const res = await patch>(endpoint, body, { + ...config, + headers: { + ...config?.headers, + }, + }); + toastSuccess(res.data?.msg || "Operation successful"); + return res.data; + } catch (err: any) { + console.error(err); + const errMsg = err?.response?.data?.msg || err.message || "An error occurred"; + toastError(errMsg); + setError(errMsg); + return null; + } finally { + setLoading(false); + } + }; + + return { patch: patchFn, loading, error }; +}; diff --git a/client/src/Hooks/UseToast.ts b/client/src/Hooks/UseToast.ts new file mode 100644 index 000000000..2ee275253 --- /dev/null +++ b/client/src/Hooks/UseToast.ts @@ -0,0 +1,23 @@ +import { toast, type ToastOptions } from "react-toastify"; +export const useToast = () => { + const showToast = (message: string, options?: ToastOptions) => { + toast.dismiss(); + const baseStyle: React.CSSProperties = { + whiteSpace: "pre-line", + wordBreak: "break-word", + }; + toast(message, { + ...options, + style: { ...baseStyle, ...(options?.style || {}) }, + }); + }; + + const toastSuccess = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "success" }); + const toastError = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "error" }); + const toastInfo = (msg: string, opts?: ToastOptions) => + showToast(msg, { ...opts, type: "info" }); + + return { showToast, toastSuccess, toastError, toastInfo }; +}; diff --git a/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx new file mode 100644 index 000000000..978d1d45d --- /dev/null +++ b/client/src/Pages/Uptime/Monitors/Components/UptimeMonitorsTable.tsx @@ -0,0 +1,276 @@ +import Stack from "@mui/material/Stack"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { + Table, + type Header, + Pagination, + StatusLabel, +} from "@/Components/v2/design-elements"; +import { HeatmapResponseTime, HistogramResponseTime } from "@/Components/v2/common"; +import { ActionsMenu } from "@/Components/v2/actions-menu"; +import { ArrowDown, ArrowUp } from "lucide-react"; + +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material/styles"; +import { useNavigate } from "react-router-dom"; +import { usePost } from "@/Hooks/UseApi"; + +import type { Check } from "@/Types/Check"; +import type { Monitor } from "@/Types/Monitor"; +import type { ActionMenuItem } from "@/Components/v2/actions-menu"; +// import type { ChartType } from "@/features/uiSlice"; + +export const MonitorTable = ({ + monitors, + refetch, + // setSelectedMonitor, + // sortField, + // setSortField, + // sortOrder, + // setSortOrder, + count, + page, + setPage, + rowsPerPage, + setRowsPerPage, +}: { + monitors: Monitor[]; + 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 navigate = useNavigate(); + const chartType = "histogram"; + const { + post, + // loading: isPosting, + // error: postError, + } = usePost(); + + // 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(`/uptime/create/${monitor.id}`); + }, + }, + // { + // id: 5, + // label: "Clone", + // action: () => { + + // }, + // }, + { + id: 6, + label: + monitor.isActive === false + ? t("pages.common.monitors.actions.resume") + : t("pages.common.monitors.actions.pause"), + action: async () => { + await post(`/monitors/pause/${monitor.id}`, {}); + refetch(); + }, + closeMenu: true, + }, + { + id: 7, + label: ( + + {t("pages.common.monitors.actions.delete")} + + ), + action: () => { + // setSelectedMonitor(monitor); + }, + closeMenu: true, + }, + ]; + }; + + const getHeaders = (chartType: string) => { + 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.uptime.table.headers.responseTime"), + render: (row) => { + if (chartType === "histogram") { + return ; + } else { + return ; + } + }, + }, + { + id: "type", + content: ( + handleSort(e, "type")} + sx={{ cursor: "pointer" }} + > + {t("common.table.headers.type")} + {/* {renderSortIcon(sortField === "type")} */} + + ), + render: (row) => { + return row.type; + }, + }, + { + id: "actions", + content: t("common.table.headers.actions"), + render: (row) => { + return ; + }, + }, + ]; + return headers; + }; + + const headers = getHeaders(chartType); + + return ( + + { + navigate(`/uptime/${row.id}`); + }} + /> + setPage(newPage)} + onRowsPerPageChange={(e) => setRowsPerPage(Number(e.target.value))} + /> + + ); +}; diff --git a/client/src/Pages/Uptime/Monitors/index.tsx b/client/src/Pages/Uptime/Monitors/index.tsx index 2d7a851d2..255a50874 100644 --- a/client/src/Pages/Uptime/Monitors/index.tsx +++ b/client/src/Pages/Uptime/Monitors/index.tsx @@ -1,39 +1,86 @@ -import { useGet } from "@/Hooks/UseApi"; -import { MonitorBasePageWithStates } from "@/Components/v2/design-elements"; -import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor"; import { FilterControls } from "@/Components/v2/monitors"; -import { useState, useMemo } from "react"; -import { useSelector } from "react-redux"; +import { + MonitorBasePageWithStates, + UpStatusBox, + DownStatusBox, + PausedStatusBox, +} from "@/Components/v2/design-elements"; +import { TextField } from "@/Components/v2/inputs"; +import Stack from "@mui/material/Stack"; +import { MonitorTable } from "@/Pages/Uptime/Monitors/Components/UptimeMonitorsTable"; +import { useTranslation } from "react-i18next"; +import useMediaQuery from "@mui/material/useMediaQuery"; +import { useGet } from "@/Hooks/UseApi"; +import type { Monitor, MonitorType, MonitorsWithChecksResponse } from "@/Types/Monitor"; +import { useState, useMemo, useCallback } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { setRowsPerPage } from "@/Features/UI/uiSlice.js"; + import type { RootState } from "@/Types/state"; +import { useTheme } from "@mui/material"; const UptimeMonitorsPage = () => { + const { t } = useTranslation(); + const theme = useTheme(); + const isSmall = useMediaQuery(theme.breakpoints.down("md")); + const dispatch = useDispatch(); + // Redux state - const rowsPerPage = useSelector((state: RootState) => state.ui.monitors.rowsPerPage); + 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 [page, setPage] = useState(0); - const [search, setSearch] = useState(""); - const [sort, setSort] = useState< - { field: string; order: "asc" | "desc" } | undefined - >(); + const [page, setPage] = useState(0); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState<{ field: string; order: "asc" | "desc" } | undefined>( + undefined + ); + + // Convert filter selections to API filter values + // Status: "up" -> true, "down" -> false + // State: "active" -> true, "paused" -> false + 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 > sort + const filterLookup = new Map([ + [toFilterStatus, "status"], + [toFilterActive, "isActive"], + ]); + const activeFilter = [...filterLookup].find(([key]) => key !== undefined); + const field = activeFilter?.[1] || (search ? "name" : sort?.field); + const filter = activeFilter?.[0] || search; + + // Default to all types when none selected + const effectiveTypes = + selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port"]; // Build URL for monitors with checks const monitorsWithChecksUrl = useMemo(() => { const params = new URLSearchParams(); - const types = - selectedTypes.length > 0 ? selectedTypes : ["http", "ping", "docker", "port"]; - types.forEach((type) => params.append("type", type)); + effectiveTypes.forEach((type) => params.append("type", type)); params.append("limit", "25"); - if (page) params.append("page", String(page)); + if (page !== undefined) params.append("page", String(page)); if (rowsPerPage) params.append("rowsPerPage", String(rowsPerPage)); - if (search) params.append("filter", search); - if (sort?.field) params.append("field", sort.field); + if (filter) params.append("filter", filter); + if (field) params.append("field", field); if (sort?.order) params.append("order", sort.order); return `/monitors/team/with-checks?${params.toString()}`; - }, [selectedTypes, page, rowsPerPage, search, sort]); + }, [effectiveTypes, page, rowsPerPage, filter, field, sort?.order]); // Data fetching const { @@ -46,10 +93,23 @@ const UptimeMonitorsPage = () => { data: monitorsWithChecksData, isLoading: monitorsWithChecksLoading, error: monitorsWithChecksError, - } = useGet(monitorsWithChecksUrl); + refetch, + } = useGet( + monitorsWithChecksUrl, + {}, + { refreshInterval: 5000 } + ); const { monitors: monitorsWithChecks, summary, count } = monitorsWithChecksData ?? {}; + // Handlers + const handleClearFilters = useCallback(() => { + setSelectedTypes([]); + setSelectedStatus(""); + setSelectedState(""); + setSearch(""); + }, []); + return ( { page="uptime" actionLink="/uptime/create" > - + + + + + + + + { + setSearch(event.target.value); + }} + /> + + { + dispatch( + setRowsPerPage({ + value: parseInt(e.target.value, 10), + table: "monitors", + }) + ); + setPage(0); + }} /> -
{JSON.stringify(monitors, null, 2)}
); }; diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index cccc56f3d..4d4a3d066 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -1,50 +1,142 @@ -export interface CheckTimingPhases { - wait: number; - dns: number; - tcp: number; - tls: number; - request: number; - firstByte: number; - download: number; - total: number; +export interface CheckMetadata { + monitorId: string; + teamId: string; + type: string; +} + +export interface CheckCpuInfo { + physical_core?: number; + logical_core?: number; + frequency?: number; + temperature?: number[]; + free_percent?: number; + usage_percent?: number; +} + +export interface CheckMemoryInfo { + total_bytes?: number; + available_bytes?: number; + used_bytes?: number; + usage_percent?: number; +} + +export interface CheckHostInfo { + os?: string; + platform?: string; + kernel_version?: string; +} + +export interface CheckCaptureInfo { + version?: string; + mode?: string; +} + +export interface CheckDiskInfo { + device?: string; + mountpoint?: string; + read_speed_bytes?: number; + write_speed_bytes?: number; + total_bytes?: number; + free_bytes?: number; + usage_percent?: number; +} + +export interface CheckErrorInfo { + metric: string[]; + err: string; +} + +export interface CheckNetworkInterfaceInfo { + name: string; + bytes_sent: number; + bytes_recv: number; + packets_sent: number; + packets_recv: number; + err_in: number; + err_out: number; + drop_in: number; + drop_out: number; + fifo_in: number; + fifo_out: number; +} + +export interface CheckAudits { + cls?: ILighthouseAudit; + si?: ILighthouseAudit; + fcp?: ILighthouseAudit; + lcp?: ILighthouseAudit; + tbt?: ILighthouseAudit; +} + +export interface ILighthouseAudit { + id?: string; + title?: string; + score?: number | null; + displayValue?: string; + numericValue?: number; + numericUnit?: string; } export interface CheckTimings { - start: string; - socket: string; - lookup: string; - connect: string; - secureConnect: string; - response: string; - end: string; - phases: CheckTimingPhases; + start?: number; + socket?: number; + lookup?: number; + connect?: number; + secureConnect?: number; + response?: number; + end?: number; + abort?: number; + error?: number; + phases?: { + wait?: number; + dns?: number; + tcp?: number; + tls?: number; + request?: number; + firstByte?: number; + download?: number; + total?: number; + }; } export interface Check { - _id: string; - monitorId: string; - type: string; - status: string; - message: string; + id: string; + metadata: CheckMetadata; + status: boolean; + originalResponseTime?: number; responseTime: number; - normalResponseTime?: number; - httpStatusCode: number; + timings?: CheckTimings; + statusCode: number; + message: string; ack: boolean; + ackAt?: string | null; expiry: string; + cpu?: CheckCpuInfo; + memory?: CheckMemoryInfo; + disk?: CheckDiskInfo[]; + host?: CheckHostInfo; + errors?: CheckErrorInfo[]; + capture?: CheckCaptureInfo; + net?: CheckNetworkInterfaceInfo[]; + accessibility?: number; + bestPractices?: number; + seo?: number; + performance?: number; + audits?: CheckAudits; + __v: number; createdAt: string; updatedAt: string; - timings: CheckTimings; } export interface GroupedCheck { _id: string; avgResponseTime: number; - count: number; + totalChecks: number; } export interface LatestCheck { - status: string; + status: boolean; responseTime: number; checkedAt: string; - _id: string; + id: string; } diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index 8eff63518..8a0f9c7e8 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,5 +1,5 @@ import type { Check } from "@/Types/Check"; -export type MonitorStatus = "up" | "down" | "initializing"; +export type MonitorStatus = boolean | undefined; export const MonitorTypes = [ "http", @@ -27,8 +27,7 @@ export interface Monitor { updatedAt: string; updatedBy: string; url: string; - __v: number; - _id: string; + id: string; } export interface MonitorWithChecks extends Monitor { @@ -38,9 +37,9 @@ export interface MonitorWithChecks extends Monitor { export interface MonitorsSummary { totalMonitors: number; - totalUp: number; - totalDown: number; - totalPaused: number; + upMonitors: number; + downMonitors: number; + pausedMonitors: number; } export interface MonitorsWithChecksResponse { diff --git a/client/src/Types/mui.d.ts b/client/src/Types/mui.d.ts index 84bcd799f..3878f9391 100644 --- a/client/src/Types/mui.d.ts +++ b/client/src/Types/mui.d.ts @@ -1,24 +1,16 @@ import "@mui/material/Button"; declare module "@mui/material/styles" { - interface Palette { - accent: Palette["primary"]; - tertiary: Palette["primary"]; - } - interface PaletteOptions { - accent?: PaletteOptions["primary"]; - tertiary?: PaletteOptions["primary"]; - } - - interface PaletteColor { - contrastTextSecondary?: string; - contrastTextTertiary?: string; - lowContrast?: string; - } + interface Palette {} + interface PaletteOptions {} } declare module "@mui/material/Button" { interface ButtonPropsColorOverrides { - accent: true; + neutral: true; } } + +declare module "@mui/material/CircularProgress" { + interface CircularProgressPropsColorOverrides {} +} diff --git a/client/src/Types/state.ts b/client/src/Types/state.ts index fc25798c4..137d67b66 100644 --- a/client/src/Types/state.ts +++ b/client/src/Types/state.ts @@ -32,6 +32,7 @@ export interface UIState { distributedUptimeEnabled: boolean; language: string; starPromptOpen: boolean; + chartType: string; } export interface RootState { diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/DataUtils.ts new file mode 100644 index 000000000..c0bbb2d2a --- /dev/null +++ b/client/src/Utils/DataUtils.ts @@ -0,0 +1,129 @@ +const MIN_OUT = 10; +const MAX_OUT = 100; + +export const normalizeResponseTimes = ( + checks: T[], + key: K +): (T & { normalResponseTime: number })[] => { + if (!Array.isArray(checks) || checks.length === 0) + return checks as (T & { + normalResponseTime: number; + })[]; + + if (checks.length === 1) { + return [ + { + ...checks[0], + normalResponseTime: 50, + }, + ]; + } + + const getVal = (check: T): number => { + const v = check[key] as unknown; + return typeof v === "number" && Number.isFinite(v) ? v : 0; + }; + + const { min, max } = checks.reduce( + (acc, check) => { + const v = getVal(check); + if (v > acc.max) acc.max = v; + if (v < acc.min) acc.min = v; + return acc; + }, + { max: -Infinity, min: Infinity } + ); + + const range = max - min || 1; + + return checks.map((check) => ({ + ...check, + normalResponseTime: MIN_OUT + ((getVal(check) - min) * (MAX_OUT - MIN_OUT)) / range, + })); +}; + +// Interpolate color between three theme colors over 0-100 range. +// 0-50 => start (success.light), 50-75 => mid (warning.light), 75-100 => end (error.light) +export const getResponseColor = ( + ms: number, + colors: { + start: string | undefined; + mid: string | undefined; + end: string | undefined; + } +): string => { + // New ranges with open high end: + // 0–300ms: interpolate start (good) -> mid (warning) + // 300–600ms: interpolate mid (warning) -> end (error) + // >600ms: solid end (error) + const safe = { ...colors }; + if (!safe.start) safe.start = "#4caf50"; // fallback green + if (!safe.mid) safe.mid = "#ff9800"; // fallback orange + if (!safe.end) safe.end = "#f44336"; // fallback red + + const toHex = (c: number) => c.toString(16).padStart(2, "0"); + const clamp = (n: number) => Math.min(255, Math.max(0, Math.round(n))); + const rgbToHex = (r: number, g: number, b: number) => + `#${toHex(clamp(r))}${toHex(clamp(g))}${toHex(clamp(b))}`; + + const normalizeToHex = (value: string) => { + const v = value.trim(); + if (v.startsWith("#")) { + const h = v.slice(1); + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h.substring(0, 6); + return `#${full.toLowerCase()}`; + } + const m = v + .replace(/\s+/g, "") + .match(/^rgba?\((\d{1,3}),(\d{1,3}),(\d{1,3})(?:,(0|0?\.\d+|1))?\)$/i); + if (m) { + const r = parseInt(m[1], 10); + const g = parseInt(m[2], 10); + const b = parseInt(m[3], 10); + return rgbToHex(r, g, b); + } + // Fallback neutral + return "#7f7f7f"; + }; + + const parseHex = (hex: string) => { + const h = hex.replace("#", ""); + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h; + const n = parseInt(full, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + }; + const mix = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); + + const v = Math.max(0, ms); + if (v <= 300) { + const t = v / 300; // 0..1 from start->mid + const s = parseHex(normalizeToHex(safe.start)); + const m = parseHex(normalizeToHex(safe.mid)); + const r = mix(s.r, m.r, t); + const g = mix(s.g, m.g, t); + const b = mix(s.b, m.b, t); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + if (v <= 600) { + const t = (v - 300) / 300; // 0..1 from mid->end + const m = parseHex(normalizeToHex(safe.mid)); + const e = parseHex(normalizeToHex(safe.end)); + const r = mix(m.r, e.r, t); + const g = mix(m.g, e.g, t); + const b = mix(m.b, e.b, t); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + return normalizeToHex(safe.end); +}; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts new file mode 100644 index 000000000..ed4e2894e --- /dev/null +++ b/client/src/Utils/MonitorUtils.ts @@ -0,0 +1,120 @@ +import type { Monitor, MonitorStatus, MonitorType } from "@/Types/Monitor"; +import type { PaletteKey } from "@/Utils/Theme/v2Theme"; +import type { ValueType } from "@/Components/v2/design-elements/StatusLabel"; + +export const getMonitorPath = (type: MonitorType): string => { + const pathMap: Record = { + http: "uptime", + port: "uptime", + ping: "uptime", + hardware: "hardware", + pagespeed: "pagespeed", + docker: "docker", + game: "game-servers", + unknown: "uptime", + }; + return pathMap[type]; +}; + +export const getStatusPalette = (status: MonitorStatus): PaletteKey => { + if (status === true) { + return "success"; + } + if (status === false) { + return "error"; + } + return "warning"; +}; + +export const getValuePalette = (value: ValueType): PaletteKey => { + const paletteMap: Record = { + positive: "success", + negative: "error", + neutral: "warning", + }; + return paletteMap[value]; +}; + +export const getStatusColor = (status: MonitorStatus, theme: any): string => { + if (status === true) { + return theme.palette.success.light; + } + + if (status === false) { + return theme.palette.error.light; + } + + return theme.palette.warning.light; +}; + +export const getResponseTimeColor = (responseTime: number): PaletteKey => { + if (responseTime < 200) { + return "success"; + } else if (responseTime < 300) { + return "warning"; + } else { + return "error"; + } +}; + +export const getInfraGaugeColor = (val: number, theme: any) => { + if (val < 50) { + return theme.palette.success.main; + } else if (val < 80) { + return theme.palette.warning.light; + } else { + return theme.palette.error.light; + } +}; + +export const getPageSpeedPalette = (score: number): PaletteKey => { + if (score >= 90) return "success"; + else if (score >= 50) return "warning"; + else return "error"; +}; + +export const formatUrl = (url: string, maxLength: number = 55) => { + if (!url) return ""; + + const strippedUrl = url.replace(/^https?:\/\//, ""); + return strippedUrl.length > maxLength + ? `${strippedUrl.slice(0, maxLength)}…` + : strippedUrl; +}; + +export interface IStatusPageHeaderConfig { + paletteKey: PaletteKey; + message: string; +} +export const getStatusPageHeaderConfig = ( + monitors: Monitor[], + t: any +): IStatusPageHeaderConfig => { + if (!monitors || monitors.length === 0) { + return { paletteKey: "error", message: "No monitors available" }; + } + + const allUp = monitors.every((monitor) => monitor.status === true); + const anyDown = monitors.some((monitor) => monitor.status === false); + const allDown = monitors.every((monitor) => monitor.status === false); + + if (allUp) + return { + paletteKey: "success", + message: t("statusPage.details.statusHeader.allUp"), + }; + if (allDown) + return { + paletteKey: "error", + message: t("statusPage.details.statusHeader.allDown"), + }; + if (anyDown) + return { + paletteKey: "warning", + message: t("statusPage.details.statusHeader.anyDown"), + }; + return { + paletteKey: "warning", + message: t("statusPage.details.statusHeader.anyDown"), + }; +}; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts new file mode 100644 index 000000000..3b629baf0 --- /dev/null +++ b/client/src/Utils/TimeUtils.ts @@ -0,0 +1,52 @@ +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import utc from "dayjs/plugin/utc"; +import timezone from "dayjs/plugin/timezone"; +import customParseFormat from "dayjs/plugin/customParseFormat"; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(customParseFormat); +dayjs.extend(duration); + +export const MS_PER_SECOND = 1000; +export const MS_PER_MINUTE = 60 * MS_PER_SECOND; +export const MS_PER_HOUR = 60 * MS_PER_MINUTE; +export const MS_PER_DAY = 24 * MS_PER_HOUR; +export const MS_PER_WEEK = MS_PER_DAY * 7; + +export const formatDateWithTz = (timestamp: string, format: string, timezone: string) => { + if (!timestamp) { + return "Unknown time"; + } + const formattedDate = dayjs(timestamp).tz(timezone).format(format); + return formattedDate; +}; + +export const tickDateFormatLookup = (range: string) => { + const tickFormatLookup: Record = { + "1h": "h:mm A", + "24h": "h:mm A", + "7d": "MM/D, h:mm A", + "30d": "ddd. M/D", + }; + const format = tickFormatLookup[range]; + if (format === undefined) { + return ""; + } + return format; +}; + +export const tooltipDateFormatLookup = (range: string) => { + const dateFormatLookup: Record = { + "1h": "ddd. MMMM D, YYYY, hh:mm A", + "24h": "ddd. MMMM D, YYYY, hh:mm A", + "7d": "ddd. MMMM D, YYYY, hh:mm A", + "30d": "ddd. MMMM D, YYYY", + }; + const format = dateFormatLookup[range]; + if (format === undefined) { + return ""; + } + return format; +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index d7c0d1cda..0610d229c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1,4 +1,27 @@ { + "common": { + "appName": "Checkmate", + "monitoringAgentName": "Capture", + "buttons": { + "toggleTheme": "Toggles light & dark" + }, + "toasts": { + "networkError": "Network error", + "checkConnection": "Please check your connection", + "unknownError": "Unknown error" + }, + "table": { + "headers": { + "actions": "Actions", + "active": "Active", + "message": "Message", + "monitor": "Monitor", + "name": "Name", + "status": "Status", + "type": "Type" + } + } + }, "submit": "Submit", "title": "Title", "distributedStatusHeaderText": "Real-time, real-device coverage", @@ -126,7 +149,31 @@ "add": "Add", "monitors": "monitors", "pages": { + "common": { + "monitors": { + "status": { + "up": "up", + "down": "down", + "paused": "paused", + "initializing": "initializing" + }, + "actions": { + "openSite": "Open site", + "details": "Details", + "incidents": "Incidents", + "configure": "Configure", + "pause": "Pause", + "resume": "Resume", + "delete": "Delete" + } + } + }, "uptime": { + "table": { + "headers": { + "responseTime": "Response time" + } + }, "fallback": { "title": "An uptime monitor is used to:", "actionButton": "Create a monitor!", @@ -136,6 +183,11 @@ "Monitor HTTP endpoints, pings, containers & ports", "Track historical uptime and reliability trends" ] + }, + "filters": { + "search": { + "placeholder": "Search monitors..." + } } } }, @@ -523,18 +575,7 @@ "game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.", "https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard." }, - "common": { - "appName": "Checkmate", - "monitoringAgentName": "Capture", - "buttons": { - "toggleTheme": "Toggles light & dark" - }, - "toasts": { - "networkError": "Network error", - "checkConnection": "Please check your connection", - "unknownError": "Unknown error" - } - }, + "auth": { "common": { "navigation": {