feat(checks): render status code 5000 as "Down" with subtype tooltip
Status code 5000 is Checkmate's internal sentinel for "no HTTP response received" (DNS failure, connection refused, timeout, TLS error, etc.). Showing the raw number to admins is confusing — the value isn't a real HTTP code and means nothing without context. This change replaces "5000" with "Down" across all check/incident surfaces, and adds a hover tooltip that explains the underlying cause based on the stored error message. Real HTTP codes (2xx/3xx/4xx/5xx) are unchanged. ## Changes - Add client/src/Utils/statusCode.ts with formatStatusCode, getStatusCodeTooltip, getStatusCodeValueType, extractErrorCode, and a message-to-subtype parser (timeout / dns / refused / tls / reset). - Wire into 6 render sites: Checks table, Uptime details Checks table, GeoChecks table + map popup, Incidents table, Incident details dialog. - Add common.statusCode.* i18n keys in en.json with subtype-specific descriptions. Tooltip appends the raw error code (ECONNREFUSED, ETIMEDOUT, CERT_HAS_EXPIRED, etc.) when present in the message. ## Tooltip examples - "queryA ECONNREFUSED www.example.com" → "The server refused the connection (port closed or service not listening). (ECONNREFUSED)" - "connect ETIMEDOUT 1.2.3.4:443" → "The server didn't respond in time (request timed out). (ETIMEDOUT)" ## Notes - No DB or server changes; statusCode field stays 5000 in storage. - Other locales fall back to English keys until translated.
This commit is contained in:
1 parent
a69f8a5ab5
commit
602769b3e7
8 files changed
+181
-24
No files matched your search
@@ -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<FlatGeoCheck | null>(null);
|
||||
const theme = useTheme();
|
||||
const isDarkMode = theme.palette.mode === "dark";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const mapPopupStyles = (
|
||||
<GlobalStyles
|
||||
@@ -174,7 +177,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => {
|
||||
>
|
||||
Status Code:
|
||||
</Typography>{" "}
|
||||
{selectedCheck.statusCode}
|
||||
{formatStatusCode(selectedCheck.statusCode, t)}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Typography
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { Table, Pagination, ValueLabel, StatusLabel } from "@/Components/design-elements";
|
||||
import {
|
||||
Table,
|
||||
Pagination,
|
||||
ValueLabel,
|
||||
StatusLabel,
|
||||
Tooltip,
|
||||
} from "@/Components/design-elements";
|
||||
import Box from "@mui/material/Box";
|
||||
import type { Header } from "@/Components/design-elements/Table";
|
||||
import type { Monitor } from "@/Types/Monitor";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateWithTz } from "@/Utils/TimeUtils";
|
||||
import {
|
||||
formatStatusCode,
|
||||
getStatusCodeTooltip,
|
||||
getStatusCodeValueType,
|
||||
} from "@/Utils/statusCode";
|
||||
import type { Check } from "@/Types/Check";
|
||||
import type { RootState } from "@/Types/state";
|
||||
import { useSelector } from "react-redux";
|
||||
@@ -29,7 +40,7 @@ export const ChecksTable = ({
|
||||
const { t } = useTranslation();
|
||||
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
|
||||
|
||||
const getHeaders = (t: Function, uiTimezone: string) => {
|
||||
const getHeaders = (t: (key: string) => string, uiTimezone: string) => {
|
||||
const headers: Header<Check>[] = [
|
||||
{
|
||||
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 = (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
value={getStatusCodeValueType(code)}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{label}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<Stack
|
||||
gap={theme.spacing(LAYOUT.MD)}
|
||||
@@ -123,7 +132,15 @@ export const CardDetails = ({ incident, monitor, sx }: CardDetailsProps) => {
|
||||
<Cell>{t("pages.incidents.dialog.details.statusCode")}</Cell>
|
||||
</Grid>
|
||||
<Grid size={4}>
|
||||
<Cell>{incident.statusCode ?? "N/A"}</Cell>
|
||||
<Cell>
|
||||
{statusCodeTooltip ? (
|
||||
<Tooltip title={statusCodeTooltip}>
|
||||
<span>{statusCodeText}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
statusCodeText
|
||||
)}
|
||||
</Cell>
|
||||
</Grid>
|
||||
<Grid size={2}>
|
||||
<Cell>{t("pages.incidents.dialog.details.downtime")}</Cell>
|
||||
|
||||
@@ -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 = (
|
||||
<ValueLabel
|
||||
value={value}
|
||||
text={String(code)}
|
||||
value={getStatusCodeValueType(code)}
|
||||
text={text}
|
||||
/>
|
||||
);
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{label}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<Check>[] = [
|
||||
{
|
||||
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 ? (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{text}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<FlatGeoCheck>[] = [
|
||||
{
|
||||
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 ? (
|
||||
<Tooltip title={tooltip}>
|
||||
<span>{text}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
text
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user