diff --git a/client/src/Components/monitors/GeoChecksMap.tsx b/client/src/Components/monitors/GeoChecksMap.tsx index 3f9429853..e013f2235 100644 --- a/client/src/Components/monitors/GeoChecksMap.tsx +++ b/client/src/Components/monitors/GeoChecksMap.tsx @@ -3,6 +3,8 @@ import { Box, Typography, Stack, useTheme, GlobalStyles } from "@mui/material"; import Map, { Marker, Popup } from "react-map-gl/maplibre"; import type { MapRef } from "react-map-gl/maplibre"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; +import { useTranslation } from "react-i18next"; +import { formatStatusCode } from "@/Utils/statusCode"; import "maplibre-gl/dist/maplibre-gl.css"; interface GeoChecksMapProps { @@ -14,6 +16,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { const [selectedCheck, setSelectedCheck] = useState(null); const theme = useTheme(); const isDarkMode = theme.palette.mode === "dark"; + const { t } = useTranslation(); const mapPopupStyles = ( { > Status Code: {" "} - {selectedCheck.statusCode} + {formatStatusCode(selectedCheck.statusCode, t)} state.ui.timezone); - const getHeaders = (t: Function, uiTimezone: string) => { + const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "monitorName", @@ -65,13 +76,21 @@ export const ChecksTable = ({ render: (row) => { const code = row.statusCode; if (!code) return "N/A"; - const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative"; - return ( + const text = formatStatusCode(code, t); + const tooltip = getStatusCodeTooltip(code, row.message, t); + const label = ( ); + return tooltip ? ( + + {label} + + ) : ( + label + ); }, }, { diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx index b6c1288dd..2cd8fbde4 100644 --- a/client/src/Pages/Incidents/Components/CardDetails.tsx +++ b/client/src/Pages/Incidents/Components/CardDetails.tsx @@ -2,7 +2,7 @@ import Stack from "@mui/material/Stack"; import Grid from "@mui/material/Grid"; import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; -import { BaseBox, ValueLabel } from "@/Components/design-elements"; +import { BaseBox, ValueLabel, Tooltip } from "@/Components/design-elements"; import { LAYOUT } from "@/Utils/Theme/constants"; import { useTranslation } from "react-i18next"; @@ -13,6 +13,7 @@ import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { formatDateWithTz } from "@/Utils/TimeUtils"; import { getIncidentsDuration } from "@/Pages/Incidents/utils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; interface CardDetailsProps { incident: Incident | null; @@ -46,6 +47,14 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => { if (!incident) { return null; } + + const statusCodeText = formatStatusCode(incident.statusCode, t); + const statusCodeTooltip = getStatusCodeTooltip( + incident.statusCode, + incident.message, + t + ); + return ( { {t("pages.incidents.dialog.details.statusCode")} - {incident.statusCode ?? "N/A"} + + {statusCodeTooltip ? ( + + {statusCodeText} + + ) : ( + statusCodeText + )} + {t("pages.incidents.dialog.details.downtime")} diff --git a/client/src/Pages/Incidents/Components/IncidentTable.tsx b/client/src/Pages/Incidents/Components/IncidentTable.tsx index bb1759dcd..c66cdb0ed 100644 --- a/client/src/Pages/Incidents/Components/IncidentTable.tsx +++ b/client/src/Pages/Incidents/Components/IncidentTable.tsx @@ -1,8 +1,6 @@ -import { Table } from "@/Components/design-elements"; +import { Table, ValueLabel, Tooltip } from "@/Components/design-elements"; import { Pagination } from "@/Components/design-elements/Table"; import type { Header } from "@/Components/design-elements/Table"; -import { ValueLabel } from "@/Components/design-elements"; -import type { ValueType } from "@/Components/design-elements/StatusLabel"; import { ActionsMenu } from "@/Components/actions-menu"; import type { Incident } from "@/Types/Incident"; import type { Monitor } from "@/Types/Monitor"; @@ -16,6 +14,11 @@ import Typography from "@mui/material/Typography"; import { useTheme } from "@mui/material/styles"; import Box from "@mui/material/Box"; import { getMonitorPath } from "@/Utils/MonitorUtils"; +import { + formatStatusCode, + getStatusCodeTooltip, + getStatusCodeValueType, +} from "@/Utils/statusCode"; interface IncidentsTableProps { title?: string; @@ -157,15 +160,21 @@ export const IncidentsTable = ({ render: (row) => { const code = row.statusCode; if (!code) return "N/A"; - let value: ValueType = "neutral"; - if (code < 300) value = "positive"; - else if (code >= 400) value = "negative"; - return ( + const text = formatStatusCode(code, t); + const tooltip = getStatusCodeTooltip(code, row.message, t); + const label = ( ); + return tooltip ? ( + + {label} + + ) : ( + label + ); }, }, { diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx index 0aaede394..0ab9a7a7c 100644 --- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx @@ -1,4 +1,4 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { Check } from "@/Types/Check"; @@ -6,10 +6,11 @@ import type { Check } from "@/Types/Check"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -const getHeaders = (t: Function, uiTimezone: string) => { +const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "status", @@ -36,7 +37,16 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + if (!row.statusCode) return "N/A"; + const text = formatStatusCode(row.statusCode, t); + const tooltip = getStatusCodeTooltip(row.statusCode, row.message, t); + return tooltip ? ( + + {text} + + ) : ( + text + ); }, }, ]; diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 31ad6a3dd..ae56e037e 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -1,14 +1,15 @@ -import { Table, Pagination, StatusLabel } from "@/Components/design-elements"; +import { Table, Pagination, StatusLabel, Tooltip } from "@/Components/design-elements"; import Box from "@mui/material/Box"; import type { Header } from "@/Components/design-elements"; import type { FlatGeoCheck } from "@/Types/GeoCheck"; import { useTranslation } from "react-i18next"; import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatStatusCode, getStatusCodeTooltip } from "@/Utils/statusCode"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; import prettyMilliseconds from "pretty-ms"; -const getHeaders = (t: Function, uiTimezone: string) => { +const getHeaders = (t: (key: string) => string, uiTimezone: string) => { const headers: Header[] = [ { id: "status", @@ -29,7 +30,16 @@ const getHeaders = (t: Function, uiTimezone: string) => { id: "statusCode", content: t("pages.checks.table.headers.statusCode"), render: (row) => { - return row.statusCode || "N/A"; + if (!row.statusCode) return "N/A"; + const text = formatStatusCode(row.statusCode, t); + const tooltip = getStatusCodeTooltip(row.statusCode, undefined, t); + return tooltip ? ( + + {text} + + ) : ( + text + ); }, }, { diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts new file mode 100644 index 000000000..75e4a9b19 --- /dev/null +++ b/client/src/Utils/statusCode.ts @@ -0,0 +1,78 @@ +import type { ValueType } from "@/Components/design-elements/StatusLabel"; + +export const NETWORK_ERROR_CODE = 5000; + +export const NETWORK_ERROR_SUBTYPES = [ + "timeout", + "dns", + "refused", + "tls", + "reset", + "unknown", +] as const; +export type NetworkErrorSubtype = (typeof NETWORK_ERROR_SUBTYPES)[number]; + +type TranslateFn = (key: string) => string; + +export const isNetworkError = (code?: number | null): boolean => + code === NETWORK_ERROR_CODE; + +const ERROR_CODE_PATTERN = + /\b(E[A-Z_]{2,}|CERT_[A-Z_]+|UNABLE_TO_VERIFY[A-Z_]*|DEPTH_ZERO_SELF_SIGNED[A-Z_]*|SELF_SIGNED[A-Z_]*)\b/; + +export const extractErrorCode = (message?: string | null): string | null => { + if (!message) return null; + const match = message.match(ERROR_CODE_PATTERN); + return match ? match[1] : null; +}; + +export const getNetworkErrorSubtype = (message?: string | null): NetworkErrorSubtype => { + if (!message) return "unknown"; + const m = message.toUpperCase(); + if (m.includes("ETIMEDOUT") || m.includes("TIMEOUT") || m.includes("ESOCKETTIMEDOUT")) + return "timeout"; + if (m.includes("ENOTFOUND") || m.includes("EAI_AGAIN") || m.includes("DNS")) + return "dns"; + if (m.includes("ECONNREFUSED")) return "refused"; + if ( + m.includes("CERT_") || + m.includes("UNABLE_TO_VERIFY") || + m.includes("DEPTH_ZERO_SELF_SIGNED") || + m.includes("SELF_SIGNED") || + m.includes("TLS") || + m.includes("SSL") + ) + return "tls"; + if (m.includes("ECONNRESET")) return "reset"; + return "unknown"; +}; + +export const formatStatusCode = ( + code: number | null | undefined, + t: TranslateFn, + fallback: string = "N/A" +): string => { + if (code == null) return fallback; + if (isNetworkError(code)) return t("common.statusCode.down"); + return String(code); +}; + +export const getStatusCodeTooltip = ( + code: number | null | undefined, + message: string | null | undefined, + t: TranslateFn +): string | null => { + if (!isNetworkError(code)) return null; + const description = t( + `common.statusCode.networkError.${getNetworkErrorSubtype(message)}` + ); + const errorCode = extractErrorCode(message); + return errorCode ? `${description} (${errorCode})` : description; +}; + +export const getStatusCodeValueType = (code: number): ValueType => { + if (isNetworkError(code)) return "negative"; + if (code < 300) return "positive"; + if (code < 400) return "neutral"; + return "negative"; +}; diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 211021276..587e25df1 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -14,6 +14,17 @@ } }, "appName": "Checkmate", + "statusCode": { + "down": "Down", + "networkError": { + "timeout": "The server didn't respond in time (request timed out).", + "dns": "Couldn't resolve the hostname (DNS lookup failed).", + "refused": "The server refused the connection (port closed or service not listening).", + "tls": "TLS or SSL handshake failed (certificate or encryption issue).", + "reset": "The connection was reset before a response was received.", + "unknown": "No HTTP response was received from the target." + } + }, "breadcrumbs": { "details": "Details", "home": "Home"