diff --git a/client/src/Components/design-elements/StatusCodeLabel.tsx b/client/src/Components/design-elements/StatusCodeLabel.tsx
new file mode 100644
index 000000000..f088550f0
--- /dev/null
+++ b/client/src/Components/design-elements/StatusCodeLabel.tsx
@@ -0,0 +1,40 @@
+import { useTranslation } from "react-i18next";
+import {
+ formatStatusCode,
+ getStatusCodeTooltip,
+ getStatusCodeValueType,
+} from "@/Utils/statusCode";
+import { ValueLabel } from "./StatusLabel";
+import { Tooltip } from "./Tooltip";
+
+export type StatusCodeLabelProps = {
+ statusCode: number | null | undefined;
+ message?: string | null;
+ fallback?: string;
+};
+
+export const StatusCodeLabel = ({
+ statusCode,
+ message,
+ fallback = "N/A",
+}: StatusCodeLabelProps) => {
+ const { t } = useTranslation();
+ if (!statusCode) {
+ return <>{fallback}>;
+ }
+
+ const text = formatStatusCode(statusCode, t);
+ const tooltip = getStatusCodeTooltip(statusCode, message, t);
+ const body = (
+
+ );
+ if (!tooltip) return body;
+ return (
+
+ {body}
+
+ );
+};
diff --git a/client/src/Components/design-elements/index.tsx b/client/src/Components/design-elements/index.tsx
index f6e2771ec..557f3bfc2 100644
--- a/client/src/Components/design-elements/index.tsx
+++ b/client/src/Components/design-elements/index.tsx
@@ -25,3 +25,4 @@ export * from "./TextLink";
export * from "./NoticeBanner";
export * from "./OfflineBanner";
export * from "./Avatar";
+export * from "./StatusCodeLabel";
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 = () => {
const headers: Header[] = [
{
id: "monitorName",
@@ -63,13 +67,10 @@ export const ChecksTable = ({
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
- const code = row.statusCode;
- if (!code) return "N/A";
- const value = code < 300 ? "positive" : code < 400 ? "neutral" : "negative";
return (
-
);
},
@@ -85,7 +86,7 @@ export const ChecksTable = ({
return headers;
};
- const headers = getHeaders(t, uiTimezone);
+ const headers = getHeaders();
const handlePageChange = (
_e: React.MouseEvent | null,
diff --git a/client/src/Pages/Incidents/Components/CardDetails.tsx b/client/src/Pages/Incidents/Components/CardDetails.tsx
index b6c1288dd..b2d72d08c 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, StatusCodeLabel } from "@/Components/design-elements";
import { LAYOUT } from "@/Utils/Theme/constants";
import { useTranslation } from "react-i18next";
@@ -46,6 +46,7 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
if (!incident) {
return null;
}
+
return (
{
| {t("pages.incidents.dialog.details.statusCode")} |
- {incident.statusCode ?? "N/A"} |
+
+
+ |
{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..399f4d8f8 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, StatusCodeLabel } 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";
@@ -136,13 +134,12 @@ export const IncidentsTable = ({
return (
{row.resolutionType}
@@ -155,15 +152,10 @@ export const IncidentsTable = ({
id: "statusCode",
content: t("pages.incidents.table.headers.statusCode"),
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 (
-
);
},
diff --git a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx
index 0aaede394..b100c28c4 100644
--- a/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx
+++ b/client/src/Pages/Uptime/Details/Components/ChecksTable.tsx
@@ -1,4 +1,9 @@
-import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
+import {
+ Table,
+ Pagination,
+ StatusLabel,
+ StatusCodeLabel,
+} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { Check } from "@/Types/Check";
@@ -9,7 +14,25 @@ import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
-const getHeaders = (t: Function, uiTimezone: string) => {
+export const ChecksTable = ({
+ checks,
+ count,
+ page,
+ setPage,
+ rowsPerPage,
+ setRowsPerPage,
+}: {
+ checks: Check[];
+ count: number;
+ page: number;
+ setPage: (page: number) => void;
+ rowsPerPage: number;
+ setRowsPerPage: (rowsPerPage: number) => void;
+}) => {
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+ const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
+
const headers: Header[] = [
{
id: "status",
@@ -36,32 +59,15 @@ const getHeaders = (t: Function, uiTimezone: string) => {
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
- return row.statusCode || "N/A";
+ return (
+
+ );
},
},
];
- return headers;
-};
-
-export const ChecksTable = ({
- checks,
- count,
- page,
- setPage,
- rowsPerPage,
- setRowsPerPage,
-}: {
- checks: Check[];
- count: number;
- page: number;
- setPage: (page: number) => void;
- rowsPerPage: number;
- setRowsPerPage: (rowsPerPage: number) => void;
-}) => {
- const navigate = useNavigate();
- const { t } = useTranslation();
- const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
- const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent | null,
diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx
index 31ad6a3dd..373e09da2 100644
--- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx
+++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx
@@ -1,4 +1,9 @@
-import { Table, Pagination, StatusLabel } from "@/Components/design-elements";
+import {
+ Table,
+ Pagination,
+ StatusLabel,
+ StatusCodeLabel,
+} from "@/Components/design-elements";
import Box from "@mui/material/Box";
import type { Header } from "@/Components/design-elements";
import type { FlatGeoCheck } from "@/Types/GeoCheck";
@@ -8,7 +13,24 @@ import type { RootState } from "@/Types/state";
import { useSelector } from "react-redux";
import prettyMilliseconds from "pretty-ms";
-const getHeaders = (t: Function, uiTimezone: string) => {
+export const GeoChecksTable = ({
+ geoChecks,
+ count,
+ page,
+ setPage,
+ rowsPerPage,
+ setRowsPerPage,
+}: {
+ geoChecks: FlatGeoCheck[];
+ count: number;
+ page: number;
+ setPage: (page: number) => void;
+ rowsPerPage: number;
+ setRowsPerPage: (rowsPerPage: number) => void;
+}) => {
+ const { t } = useTranslation();
+ const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
+
const headers: Header[] = [
{
id: "status",
@@ -29,7 +51,7 @@ const getHeaders = (t: Function, uiTimezone: string) => {
id: "statusCode",
content: t("pages.checks.table.headers.statusCode"),
render: (row) => {
- return row.statusCode || "N/A";
+ return ;
},
},
{
@@ -51,27 +73,6 @@ const getHeaders = (t: Function, uiTimezone: string) => {
},
},
];
- return headers;
-};
-
-export const GeoChecksTable = ({
- geoChecks,
- count,
- page,
- setPage,
- rowsPerPage,
- setRowsPerPage,
-}: {
- geoChecks: FlatGeoCheck[];
- count: number;
- page: number;
- setPage: (page: number) => void;
- rowsPerPage: number;
- setRowsPerPage: (rowsPerPage: number) => void;
-}) => {
- const { t } = useTranslation();
- const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
- const headers = getHeaders(t, uiTimezone);
const handlePageChange = (
_e: React.MouseEvent | null,
diff --git a/client/src/Utils/statusCode.ts b/client/src/Utils/statusCode.ts
new file mode 100644
index 000000000..33a5f3de1
--- /dev/null
+++ b/client/src/Utils/statusCode.ts
@@ -0,0 +1,76 @@
+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
+): string => {
+ 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"