From 65c6f567b51187006441badb141c19ab9d83a061 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 11:55:05 -0700 Subject: [PATCH 1/6] add ms formatting util --- client/src/Utils/TimeUtils.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 5b63de87e..85890ca4f 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -3,6 +3,7 @@ import duration from "dayjs/plugin/duration"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import customParseFormat from "dayjs/plugin/customParseFormat"; +import prettyMilliseconds from "pretty-ms"; dayjs.extend(utc); dayjs.extend(timezone); @@ -77,3 +78,11 @@ export const formatTimestamp = (timestamp: string | number | null): string => { const date = new Date(timestamp); return date.toLocaleString(); }; + +export const formatMs = (ms: number): string => { + return prettyMilliseconds(ms, { + compact: true, + formatSubMilliseconds: true, + secondsDecimalDigits: 0, + }); +}; From 336dbe4de2c9b93536124c69c3cf1ebaaaabbfac Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 12:40:18 -0700 Subject: [PATCH 2/6] use formatMs function to consistently format point times --- .../charts/HeatmapResponseTimeTooltip.tsx | 7 +-- .../common/charts/HistogramResponseTime.tsx | 56 +++++++++---------- .../src/Components/monitors/GeoChecksMap.tsx | 7 +-- .../Components/monitors/MonitorStatBoxes.tsx | 3 +- .../monitors/charts/HistogramDetails.tsx | 18 ++---- .../monitors/charts/HistogramStatus.tsx | 4 +- .../monitors/charts/RadialAvgResponse.tsx | 5 +- client/src/Pages/Logs/components/Stats.tsx | 5 +- .../themes/shared/ThemedChartTooltip.tsx | 5 +- .../Status/themes/shared/ThemedHeatmap.tsx | 3 +- .../Status/themes/shared/ThemedHistogram.tsx | 9 +-- .../Details/Components/GeoChecksTable.tsx | 5 +- client/src/Utils/TimeUtils.ts | 21 ++++++- client/src/locales/ar.json | 8 +-- client/src/locales/ca.json | 8 +-- client/src/locales/cs.json | 8 +-- client/src/locales/de.json | 8 +-- client/src/locales/en.json | 8 +-- client/src/locales/es.json | 8 +-- client/src/locales/fi.json | 8 +-- client/src/locales/fr.json | 8 +-- client/src/locales/it.json | 8 +-- client/src/locales/ja.json | 8 +-- client/src/locales/pl.json | 8 +-- client/src/locales/pt-BR.json | 8 +-- client/src/locales/ru.json | 8 +-- client/src/locales/th.json | 8 +-- client/src/locales/tr.json | 8 +-- client/src/locales/uk.json | 8 +-- client/src/locales/vi.json | 8 +-- client/src/locales/zh-CN.json | 8 +-- client/src/locales/zh-TW.json | 8 +-- 32 files changed, 153 insertions(+), 147 deletions(-) diff --git a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx index 38ad25b9f..66c502f72 100644 --- a/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx +++ b/client/src/Components/common/charts/HeatmapResponseTimeTooltip.tsx @@ -1,13 +1,12 @@ import Stack from "@mui/material/Stack"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import type { CheckSnapshot } from "@/Types/Check"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; import type { RootState } from "@/Types/state"; import { useTranslation } from "react-i18next"; -import prettyMilliseconds from "pretty-ms"; type HeatmapCheck = | CheckSnapshot @@ -59,9 +58,7 @@ export const HeatmapResponseTimeTooltip = ({ {t("common.labels.responseTime")}:{" "} - {check?.responseTime - ? prettyMilliseconds(check.responseTime, { compact: true }) - : "N/A"} + {check?.responseTime ? formatMs(check.responseTime) : "N/A"} Status:{" "} diff --git a/client/src/Components/common/charts/HistogramResponseTime.tsx b/client/src/Components/common/charts/HistogramResponseTime.tsx index eca798011..e2924d29f 100644 --- a/client/src/Components/common/charts/HistogramResponseTime.tsx +++ b/client/src/Components/common/charts/HistogramResponseTime.tsx @@ -9,6 +9,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { computeBarHeights } from "@/Utils/DataUtils"; +import { formatMs } from "@/Utils/TimeUtils"; interface HistogramResponseTimeProps { checks: CheckSnapshot[]; @@ -19,25 +20,25 @@ interface HistogramResponseTimeProps { } interface ResponseTimeStats { - max: number | string; - avg: number | string; + max: string | null; + avg: string | null; } const DEFAULT_HEIGHT = 50; const calculateResponseTimeStats = (checks: CheckSnapshot[]): ResponseTimeStats => { if (!Array.isArray(checks) || checks.length === 0) { - return { max: "-", avg: "-" }; + return { max: null, avg: null }; } const validChecks = checks.filter((check) => check.responseTime != null); if (validChecks.length === 0) { - return { max: "-", avg: "-" }; + return { max: null, avg: null }; } const responseTimes = validChecks.map((check) => check.responseTime); - const max = Math.round(Math.max(...responseTimes)); - const avg = Math.round( + const max = formatMs(Math.max(...responseTimes)); + const avg = formatMs( responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length ); @@ -69,29 +70,28 @@ export const HistogramResponseTime = ({ const chartHeight = typeof height === "number" ? `${height}px` : height; const gridGap = gap ?? theme.spacing(0.5); - const statsContent = showStats && - (typeof stats.max === "number" || typeof stats.avg === "number") && ( - + - - {t("common.charts.histogram.avg", { value: stats.avg })} - - - {t("common.charts.histogram.max", { value: stats.max })} - - - ); + {t("common.charts.histogram.avg", { value: stats.avg })} + + + {t("common.charts.histogram.max", { value: stats.max })} + + + ); return ( { return status ? theme.palette.success.main : theme.palette.error.main; }; - const formatResponseTime = (timing: number): string => { - return `${timing.toFixed(0)}ms`; - }; - return ( <> {mapPopupStyles} @@ -186,7 +183,7 @@ export const GeoChecksMap = ({ geoChecks }: GeoChecksMapProps) => { > Response Time: {" "} - {formatResponseTime(selectedCheck.timings.total)} + {formatMs(selectedCheck.timings.total)} {monitor?.type === "http" && ( diff --git a/client/src/Components/monitors/charts/HistogramDetails.tsx b/client/src/Components/monitors/charts/HistogramDetails.tsx index f0d501327..a9af6d117 100644 --- a/client/src/Components/monitors/charts/HistogramDetails.tsx +++ b/client/src/Components/monitors/charts/HistogramDetails.tsx @@ -14,11 +14,10 @@ import { import type { TooltipProps } from "recharts"; import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; import Typography from "@mui/material/Typography"; -import prettyMilliseconds from "pretty-ms"; import { XTick } from "@/Components/monitors/charts/XTick"; import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; -import { formatDateWithTz, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs, tooltipDateFormatLookup } from "@/Utils/TimeUtils"; import { useTheme } from "@mui/material/styles"; import type { Theme } from "@mui/material/styles"; import type { GroupedCheck, GroupedUptimeCheck } from "@/Types/Check"; @@ -56,16 +55,13 @@ const ResponseTimeToolTip = ({ if (!active) return null; const format = tooltipDateFormatLookup(range); - const responseTime = Math.floor(payload[0]?.payload?.avgResponseTime || 0); + const responseTime = payload[0]?.payload?.avgResponseTime ?? 0; return ( {formatDateWithTz(String(label), format, uiTimezone)} {t("common.charts.histogram.responseTime", { - value: prettyMilliseconds(responseTime, { - formatSubMilliseconds: true, - compact: true, - }), + value: formatMs(responseTime), })} @@ -94,7 +90,7 @@ const TimingPhasesToolTip = ({ {formatDateWithTz(bucket.bucketDate, format, uiTimezone)} - {t("common.charts.histogram.total", { value: prettyMilliseconds(total) })} + {t("common.charts.histogram.total", { value: formatMs(total) })} {PHASE_KEYS.map((key) => ( - {t(PHASE_LABEL_KEYS[key])}:{" "} - {prettyMilliseconds(bucket[key], { - formatSubMilliseconds: true, - compact: true, - })} + {t(PHASE_LABEL_KEYS[key])}: {formatMs(bucket[key])} ))} diff --git a/client/src/Components/monitors/charts/HistogramStatus.tsx b/client/src/Components/monitors/charts/HistogramStatus.tsx index 44f6bea97..846510e09 100644 --- a/client/src/Components/monitors/charts/HistogramStatus.tsx +++ b/client/src/Components/monitors/charts/HistogramStatus.tsx @@ -3,7 +3,7 @@ import Typography from "@mui/material/Typography"; import { BaseChart } from "@/Components/design-elements"; import { useTheme } from "@mui/material/styles"; import { useSelector } from "react-redux"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import { ResponsiveContainer, BarChart, XAxis, Bar, Cell, Tooltip } from "recharts"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; @@ -100,7 +100,7 @@ export const HistogramStatus = ({ ) : null} {titleText} - {Math.floor(avg)} ms + {formatMs(avg)} ); }; diff --git a/client/src/Components/monitors/charts/RadialAvgResponse.tsx b/client/src/Components/monitors/charts/RadialAvgResponse.tsx index dccb18984..98864b48e 100644 --- a/client/src/Components/monitors/charts/RadialAvgResponse.tsx +++ b/client/src/Components/monitors/charts/RadialAvgResponse.tsx @@ -7,6 +7,7 @@ import { Cell, RadialBarChart, RadialBar, ResponsiveContainer } from "recharts"; import { useTranslation } from "react-i18next"; import { getResponseTimeColor } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; +import { formatMs } from "@/Utils/TimeUtils"; export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => { const { t } = useTranslation(); @@ -84,7 +85,9 @@ export const RadialAvgResponse = ({ avg, max }: { avg: number; max: number }) => {`${avg?.toFixed()}ms`} + > + {formatMs(avg)} + diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 9fe04fa9d..5cee4fe87 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -6,6 +6,7 @@ import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; +import { formatMs } from "@/Utils/TimeUtils"; interface StatsProps { diagnostics: Diagnostics | null; @@ -18,9 +19,7 @@ export const Stats = ({ diagnostics }: StatsProps) => { const theme = useTheme(); const eventLoopDelay = diagnostics - ? prettyMilliseconds(diagnostics.eventLoopDelayMs ?? 0, { - millisecondsDecimalDigits: 2, - }) + ? formatMs(diagnostics.eventLoopDelayMs ?? 0) : PLACEHOLDER; const uptime = diagnostics ? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true }) diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx index 597000b17..bc3def0d4 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -1,8 +1,7 @@ import type { CheckSnapshot } from "@/Types/Check"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; -import prettyMilliseconds from "pretty-ms"; -import { formatDateWithTz } from "@/Utils/TimeUtils"; +import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useTranslation } from "react-i18next"; @@ -16,7 +15,7 @@ export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { fontWeight={600} > {check.status - ? `${prettyMilliseconds(check.responseTime, { compact: true })}` + ? formatMs(check.responseTime) : t("pages.statusPages.monitorsList.chart.downTooltip")} { > ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index c83010e7d..a5c0b71f1 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next"; import type { CheckSnapshot } from "@/Types/Check"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; import { computeBarHeights } from "@/Utils/DataUtils"; +import { formatMs } from "@/Utils/TimeUtils"; import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; const CELLS = MAX_RECENT_CHECKS; const MIN_HEIGHT_PCT = 6; @@ -41,11 +42,11 @@ export const ThemedHistogram = ({ ]; const heights = computeBarHeights(source); const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); - const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 1; + const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 0; const avgRt = valid.length - ? Math.round(valid.reduce((s, c) => s + c.responseTime, 0) / valid.length) + ? valid.reduce((s, c) => s + c.responseTime, 0) / valid.length : 0; - return { padded: out, heights, avg: avgRt, peak: maxRt }; + return { padded: out, heights, avg: formatMs(avgRt), peak: formatMs(maxRt) }; }, [checks]); return ( @@ -71,7 +72,7 @@ export const ThemedHistogram = ({ > ); diff --git a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx index 373e09da2..2144c8769 100644 --- a/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx +++ b/client/src/Pages/Uptime/Details/Components/GeoChecksTable.tsx @@ -8,10 +8,9 @@ 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 { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; import type { RootState } from "@/Types/state"; import { useSelector } from "react-redux"; -import prettyMilliseconds from "pretty-ms"; export const GeoChecksTable = ({ geoChecks, @@ -69,7 +68,7 @@ export const GeoChecksTable = ({ content: t("common.table.headers.responseTime"), render: (row) => { if (!row.timings?.total) return "N/A"; - return prettyMilliseconds(row.timings.total, { compact: true }); + return formatMs(row.timings.total); }, }, ]; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 85890ca4f..78bdc42a4 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -79,10 +79,27 @@ export const formatTimestamp = (timestamp: string | number | null): string => { return date.toLocaleString(); }; -export const formatMs = (ms: number): string => { - return prettyMilliseconds(ms, { +const DISPLAY_UNITS = [MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND]; + +// pretty-ms floors seconds-and-above and truncates fractional ms, so values +// must be rounded to their display unit first to land on the nearest whole +// unit (53.6 → "54 ms", 1937 → "2 s", 59500 → "1 m"). +const roundToDisplayUnit = (ms: number): number => { + for (const unit of DISPLAY_UNITS) { + if (ms >= unit) return Math.round(ms / unit) * unit; + } + return ms >= 1 ? Math.round(ms) : ms; +}; + +export const formatMs = (ms: number, hasSpace: boolean = true): string => { + const formatted = prettyMilliseconds(roundToDisplayUnit(ms), { compact: true, formatSubMilliseconds: true, secondsDecimalDigits: 0, }); + + if (hasSpace) { + return formatted.replace(/^(\d+)(\D+)$/, "$1 $2"); + } + return formatted; }; diff --git a/client/src/locales/ar.json b/client/src/locales/ar.json index 6e315bd9a..55f34650d 100644 --- a/client/src/locales/ar.json +++ b/client/src/locales/ar.json @@ -58,8 +58,8 @@ "uptime": "وقت التشغيل" }, "histogram": { - "avg": "المتوسط: {{value}} مللي ثانية", - "max": "الحد الأقصى: {{value}} مللي ثانية" + "avg": "المتوسط: {{value}}", + "max": "الحد الأقصى: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "وقت الاستجابة" }, "chart": { - "avg": "متوسط {{value}} مللي ثانية", + "avg": "متوسط {{value}}", "downTooltip": "متوقف", "heatmapAria": "خريطة حرارية لزمن الاستجابة", - "max": "أقصى {{value}} مللي ثانية" + "max": "أقصى {{value}}" }, "status": { "breached": "تم تجاوز الحد", diff --git a/client/src/locales/ca.json b/client/src/locales/ca.json index 3c1a29613..7e39eca86 100644 --- a/client/src/locales/ca.json +++ b/client/src/locales/ca.json @@ -58,8 +58,8 @@ "uptime": "Temps en línia" }, "histogram": { - "avg": "Mitjana: {{value}} ms", - "max": "Màx: {{value}} ms" + "avg": "Mitjana: {{value}}", + "max": "Màx: {{value}}" } }, "dialogs": { @@ -1346,8 +1346,8 @@ "initializing": "Inicialitzant" }, "chart": { - "avg": "mitj {{value}}ms", - "max": "màx {{value}}ms", + "avg": "mitj {{value}}", + "max": "màx {{value}}", "downTooltip": "Caigut", "heatmapAria": "Mapa de calor del temps de resposta" }, diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json index ae683aefe..d04b9b23b 100644 --- a/client/src/locales/cs.json +++ b/client/src/locales/cs.json @@ -58,8 +58,8 @@ "uptime": "Dostupnost" }, "histogram": { - "avg": "Prům.: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Prům.: {{value}}", + "max": "Max: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Doba odezvy" }, "chart": { - "avg": "průměr {{value}}ms", + "avg": "průměr {{value}}", "downTooltip": "Mimo provoz", "heatmapAria": "Tepelná mapa doby odezvy", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Překročen limit", diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 7b2e64945..d43d5d52a 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -58,8 +58,8 @@ "uptime": "Verfügbarkeit" }, "histogram": { - "avg": "Durchschn.: {{value}} ms", - "max": "Max.: {{value}} ms" + "avg": "Durchschn.: {{value}}", + "max": "Max.: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Antwortzeit" }, "chart": { - "avg": "Ø {{value}}ms", + "avg": "Ø {{value}}", "downTooltip": "Ausgefallen", "heatmapAria": "Heatmap der Antwortzeit", - "max": "max. {{value}}ms" + "max": "max. {{value}}" }, "status": { "breached": "Schwellenwert überschritten", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ce7f74dfb..955e9727f 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -74,8 +74,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Avg: {{value}} ms", - "max": "Max: {{value}} ms", + "avg": "Avg: {{value}}", + "max": "Max: {{value}}", "responseTime": "Response time: {{value}}", "total": "Total: {{value}}" }, @@ -1455,8 +1455,8 @@ "initializing": "Initializing" }, "chart": { - "avg": "avg {{value}}ms", - "max": "max {{value}}ms", + "avg": "avg {{value}}", + "max": "max {{value}}", "downTooltip": "Down", "heatmapAria": "Response time heatmap" }, diff --git a/client/src/locales/es.json b/client/src/locales/es.json index 1c45ecad4..589399a83 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -58,8 +58,8 @@ "uptime": "Tiempo de actividad" }, "histogram": { - "avg": "Prom: {{value}} ms", - "max": "Máx: {{value}} ms" + "avg": "Prom: {{value}}", + "max": "Máx: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Tiempo de respuesta" }, "chart": { - "avg": "prom {{value}}ms", + "avg": "prom {{value}}", "downTooltip": "Caído", "heatmapAria": "Mapa de calor del tiempo de respuesta", - "max": "máx {{value}}ms" + "max": "máx {{value}}" }, "status": { "breached": "Umbral superado", diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json index 3ca1de82a..030698d44 100644 --- a/client/src/locales/fi.json +++ b/client/src/locales/fi.json @@ -58,8 +58,8 @@ "uptime": "Käytettävyys" }, "histogram": { - "avg": "Keskim.: {{value}} ms", - "max": "Maks.: {{value}} ms" + "avg": "Keskim.: {{value}}", + "max": "Maks.: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Vasteaika" }, "chart": { - "avg": "ka {{value}}ms", + "avg": "ka {{value}}", "downTooltip": "Alhaalla", "heatmapAria": "Vasteajan lämpökartta", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Kynnys ylittyi", diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json index 9b3e983a6..1e0a6b096 100644 --- a/client/src/locales/fr.json +++ b/client/src/locales/fr.json @@ -58,8 +58,8 @@ "uptime": "Disponibilité" }, "histogram": { - "avg": "Moy : {{value}} ms", - "max": "Max : {{value}} ms" + "avg": "Moy : {{value}}", + "max": "Max : {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Temps de réponse" }, "chart": { - "avg": "moy {{value}}ms", + "avg": "moy {{value}}", "downTooltip": "Hors service", "heatmapAria": "Carte thermique des temps de réponse", - "max": "max {{value}}ms" + "max": "max {{value}}" }, "status": { "breached": "Seuil dépassé", diff --git a/client/src/locales/it.json b/client/src/locales/it.json index cabeb94ef..3d6d1ea16 100644 --- a/client/src/locales/it.json +++ b/client/src/locales/it.json @@ -71,8 +71,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Media: {{value}} ms", - "max": "Max: {{value}} ms" + "avg": "Media: {{value}}", + "max": "Max: {{value}}" } }, "dialogs": { @@ -1442,8 +1442,8 @@ "initializing": "Inizializzazione" }, "chart": { - "avg": "media {{value}}ms", - "max": "max {{value}}ms", + "avg": "media {{value}}", + "max": "max {{value}}", "downTooltip": "Non attivo", "heatmapAria": "Heatmap del tempo di risposta" }, diff --git a/client/src/locales/ja.json b/client/src/locales/ja.json index 031350b20..16e2ec641 100644 --- a/client/src/locales/ja.json +++ b/client/src/locales/ja.json @@ -58,8 +58,8 @@ "uptime": "稼働時間" }, "histogram": { - "avg": "平均: {{value}} ms", - "max": "最大: {{value}} ms" + "avg": "平均: {{value}}", + "max": "最大: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "応答時間" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "ダウン", "heatmapAria": "応答時間のヒートマップ", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "しきい値超過", diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json index 9a74b6cdb..81509ee6f 100644 --- a/client/src/locales/pl.json +++ b/client/src/locales/pl.json @@ -53,8 +53,8 @@ }, "charts": { "histogram": { - "avg": "Średnia: {{value}} ms", - "max": "Maks: {{value}} ms" + "avg": "Średnia: {{value}}", + "max": "Maks: {{value}}" }, "labels": { "averageResponseTime": "Średni czas odpowiedzi", @@ -1542,10 +1542,10 @@ }, "monitorsList": { "chart": { - "avg": "śr. {{value}}ms", + "avg": "śr. {{value}}", "downTooltip": "Niedostępny", "heatmapAria": "Heatmapa czasu odpowiedzi", - "max": "maks. {{value}}ms" + "max": "maks. {{value}}" }, "chartTypeHeatmap": "Heatmapa", "chartTypeHistogram": "Histogram", diff --git a/client/src/locales/pt-BR.json b/client/src/locales/pt-BR.json index 0286f2857..c75ead3ef 100644 --- a/client/src/locales/pt-BR.json +++ b/client/src/locales/pt-BR.json @@ -58,8 +58,8 @@ "uptime": "Uptime" }, "histogram": { - "avg": "Média: {{value}} ms", - "max": "Máx: {{value}} ms" + "avg": "Média: {{value}}", + "max": "Máx: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Tempo de resposta" }, "chart": { - "avg": "méd {{value}}ms", + "avg": "méd {{value}}", "downTooltip": "Fora do ar", "heatmapAria": "Mapa de calor do tempo de resposta", - "max": "máx {{value}}ms" + "max": "máx {{value}}" }, "status": { "breached": "Limite excedido", diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json index 50226037a..cf5e486f7 100644 --- a/client/src/locales/ru.json +++ b/client/src/locales/ru.json @@ -58,8 +58,8 @@ "uptime": "Аптайм" }, "histogram": { - "avg": "Сред: {{value}} мс", - "max": "Макс: {{value}} мс" + "avg": "Сред: {{value}}", + "max": "Макс: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Время ответа" }, "chart": { - "avg": "ср {{value}}мс", + "avg": "ср {{value}}", "downTooltip": "Не работает", "heatmapAria": "Тепловая карта времени отклика", - "max": "макс {{value}}мс" + "max": "макс {{value}}" }, "status": { "breached": "Превышен порог", diff --git a/client/src/locales/th.json b/client/src/locales/th.json index c0889a72f..66d354adc 100644 --- a/client/src/locales/th.json +++ b/client/src/locales/th.json @@ -58,8 +58,8 @@ "uptime": "อัปไทม์" }, "histogram": { - "avg": "เฉลี่ย: {{value}} ms", - "max": "สูงสุด: {{value}} ms" + "avg": "เฉลี่ย: {{value}}", + "max": "สูงสุด: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "เวลาตอบสนอง" }, "chart": { - "avg": "เฉลี่ย {{value}}ms", + "avg": "เฉลี่ย {{value}}", "downTooltip": "ไม่พร้อมใช้งาน", "heatmapAria": "แผนที่ความร้อนของเวลาตอบสนอง", - "max": "สูงสุด {{value}}ms" + "max": "สูงสุด {{value}}" }, "status": { "breached": "เกินขีดจำกัด", diff --git a/client/src/locales/tr.json b/client/src/locales/tr.json index c82091a83..26f8aace0 100644 --- a/client/src/locales/tr.json +++ b/client/src/locales/tr.json @@ -58,8 +58,8 @@ "uptime": "Çalışma süresi" }, "histogram": { - "avg": "Ort: {{value}} ms", - "max": "Maks: {{value}} ms" + "avg": "Ort: {{value}}", + "max": "Maks: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Yanıt süresi" }, "chart": { - "avg": "ort {{value}}ms", + "avg": "ort {{value}}", "downTooltip": "Çalışmıyor", "heatmapAria": "Yanıt süresi ısı haritası", - "max": "maks {{value}}ms" + "max": "maks {{value}}" }, "status": { "breached": "Eşik aşıldı", diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json index 6c3b47b66..d1cb983d1 100644 --- a/client/src/locales/uk.json +++ b/client/src/locales/uk.json @@ -58,8 +58,8 @@ "uptime": "Час роботи" }, "histogram": { - "avg": "Сер: {{value}} мс", - "max": "Макс: {{value}} мс" + "avg": "Сер: {{value}}", + "max": "Макс: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Час відповіді" }, "chart": { - "avg": "сер {{value}}мс", + "avg": "сер {{value}}", "downTooltip": "Не працює", "heatmapAria": "Теплова карта часу відгуку", - "max": "макс {{value}}мс" + "max": "макс {{value}}" }, "status": { "breached": "Перевищено поріг", diff --git a/client/src/locales/vi.json b/client/src/locales/vi.json index 72c933601..81da1c6fc 100644 --- a/client/src/locales/vi.json +++ b/client/src/locales/vi.json @@ -58,8 +58,8 @@ "uptime": "Thời gian hoạt động" }, "histogram": { - "avg": "TB: {{value}} ms", - "max": "Tối đa: {{value}} ms" + "avg": "TB: {{value}}", + "max": "Tối đa: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "Thời gian phản hồi" }, "chart": { - "avg": "TB {{value}}ms", + "avg": "TB {{value}}", "downTooltip": "Ngừng hoạt động", "heatmapAria": "Bản đồ nhiệt thời gian phản hồi", - "max": "tối đa {{value}}ms" + "max": "tối đa {{value}}" }, "status": { "breached": "Vượt ngưỡng", diff --git a/client/src/locales/zh-CN.json b/client/src/locales/zh-CN.json index 4499dd5fb..bef6bebde 100644 --- a/client/src/locales/zh-CN.json +++ b/client/src/locales/zh-CN.json @@ -58,8 +58,8 @@ "uptime": "正常运行时间" }, "histogram": { - "avg": "平均: {{value}} ms", - "max": "最大: {{value}} ms" + "avg": "平均: {{value}}", + "max": "最大: {{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "响应时间" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "宕机", "heatmapAria": "响应时间热图", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "超出阈值", diff --git a/client/src/locales/zh-TW.json b/client/src/locales/zh-TW.json index 7b560ae7d..a15a2a2bb 100644 --- a/client/src/locales/zh-TW.json +++ b/client/src/locales/zh-TW.json @@ -58,8 +58,8 @@ "uptime": "正常運行時間" }, "histogram": { - "avg": "平均:{{value}} ms", - "max": "最大:{{value}} ms" + "avg": "平均:{{value}}", + "max": "最大:{{value}}" } }, "dialogs": { @@ -1352,10 +1352,10 @@ "responseTime": "回應時間" }, "chart": { - "avg": "平均 {{value}}ms", + "avg": "平均 {{value}}", "downTooltip": "停機", "heatmapAria": "回應時間熱圖", - "max": "最大 {{value}}ms" + "max": "最大 {{value}}" }, "status": { "breached": "超過閾值", From 19b5976f856f4898948fc5a25ec2033deb69b00e Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 12:58:58 -0700 Subject: [PATCH 3/6] extract formatting durations --- .../Components/monitors/MonitorStatBoxes.tsx | 12 +++--------- client/src/Pages/Incidents/utils.ts | 7 ++----- client/src/Pages/Logs/components/Stats.tsx | 6 ++---- client/src/Pages/Logs/components/TableJobs.tsx | 5 ++--- .../Maintenance/MaintenanceWindowTable.tsx | 8 +++----- client/src/Utils/TimeUtils.ts | 17 +++++++++++++++++ 6 files changed, 29 insertions(+), 26 deletions(-) diff --git a/client/src/Components/monitors/MonitorStatBoxes.tsx b/client/src/Components/monitors/MonitorStatBoxes.tsx index 914e76ffb..58176dc04 100644 --- a/client/src/Components/monitors/MonitorStatBoxes.tsx +++ b/client/src/Components/monitors/MonitorStatBoxes.tsx @@ -1,12 +1,11 @@ import Stack from "@mui/material/Stack"; import { StatBox } from "@/Components/design-elements"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material/styles"; import type { MonitorStats, Monitor } from "@/Types/Monitor"; import { getStatusPalette } from "@/Utils/MonitorUtils"; import { useTranslation } from "react-i18next"; -import { formatMs } from "@/Utils/TimeUtils"; +import { formatMs, formatDuration } from "@/Utils/TimeUtils"; interface MonitorStatBoxesProps { monitor?: Monitor; @@ -32,14 +31,9 @@ export const MonitorStatBoxes = ({ const timeOfLastCheck = monitorStats?.lastCheckTimestamp || 0; const timeSinceLastCheck = Date.now() - timeOfLastCheck || 0; - const options = { - secondsDecimalDigits: 0, - millisecondsDecimalDigits: 0, - }; + const streakTime = formatDuration(timeSinceLastFailure); - const streakTime = prettyMilliseconds(timeSinceLastFailure, options); - - const lastCheckTime = prettyMilliseconds(timeSinceLastCheck, options); + const lastCheckTime = formatDuration(timeSinceLastCheck); const isActive = monitor?.status === "up" || monitor?.status === "paused" || diff --git a/client/src/Pages/Incidents/utils.ts b/client/src/Pages/Incidents/utils.ts index 6ff518a18..3407c40b4 100644 --- a/client/src/Pages/Incidents/utils.ts +++ b/client/src/Pages/Incidents/utils.ts @@ -1,5 +1,5 @@ import type { Incident, IncidentSummaryItem } from "@/Types/Incident"; -import prettyMilliseconds from "pretty-ms"; +import { formatDuration } from "@/Utils/TimeUtils"; type IncidentLike = Pick; @@ -24,8 +24,5 @@ export const getIncidentsDuration = (incident: IncidentLike | IncidentSummaryIte return "-"; } - return prettyMilliseconds(durationMs, { - secondsDecimalDigits: 0, - millisecondsDecimalDigits: 0, - }); + return formatDuration(durationMs); }; diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 5cee4fe87..884fa9c18 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -6,7 +6,7 @@ import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; -import { formatMs } from "@/Utils/TimeUtils"; +import { formatDuration, formatMs } from "@/Utils/TimeUtils"; interface StatsProps { diagnostics: Diagnostics | null; @@ -21,9 +21,7 @@ export const Stats = ({ diagnostics }: StatsProps) => { const eventLoopDelay = diagnostics ? formatMs(diagnostics.eventLoopDelayMs ?? 0) : PLACEHOLDER; - const uptime = diagnostics - ? prettyMilliseconds(diagnostics.uptimeMs ?? 0, { hideSeconds: true }) - : PLACEHOLDER; + const uptime = diagnostics ? formatDuration(diagnostics.uptimeMs ?? 0) : PLACEHOLDER; const usedHeap = diagnostics ? prettyBytes(diagnostics.v8HeapStats?.usedHeapSizeBytes ?? 0) : PLACEHOLDER; diff --git a/client/src/Pages/Logs/components/TableJobs.tsx b/client/src/Pages/Logs/components/TableJobs.tsx index 26fe62bb3..c8c287a9e 100644 --- a/client/src/Pages/Logs/components/TableJobs.tsx +++ b/client/src/Pages/Logs/components/TableJobs.tsx @@ -1,7 +1,6 @@ import { Table, Pagination } from "@/Components/design-elements"; import { Typography, useTheme } from "@mui/material"; -import prettyMilliseconds from "pretty-ms"; -import { formatTimestamp } from "@/Utils/TimeUtils"; +import { formatDuration, formatTimestamp } from "@/Utils/TimeUtils"; import { useTranslation } from "react-i18next"; import type { QueueJobFailure, QueueJobSummary, QueueMetrics } from "@/Types/Queue"; @@ -84,7 +83,7 @@ export const TableJobs = ({ id: "interval", content: t("common.table.headers.interval"), render: (row) => ( - {prettyMilliseconds(row.monitorInterval ?? 0)} + {formatDuration(row.monitorInterval ?? 0)} ), }, { diff --git a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx index 6dadb5a9c..7cd543007 100644 --- a/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx +++ b/client/src/Pages/Maintenance/MaintenanceWindowTable.tsx @@ -4,7 +4,6 @@ import { Pagination } from "@/Components/design-elements/Table"; import { ActionsMenu } from "@/Components/actions-menu"; import { DialogInput } from "@/Components/inputs/Dialog"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import type { Header } from "@/Components/design-elements/Table"; import type { ActionMenuItem } from "@/Components/actions-menu"; @@ -18,6 +17,7 @@ import { setRowsPerPage } from "@/Features/UI/uiSlice"; import dayjs from "dayjs"; import { useState } from "react"; import { useDelete, usePatch } from "@/Hooks/UseApi"; +import { formatDuration } from "@/Utils/TimeUtils"; interface MaintenanceWindowTableProps { maintenanceWindows: MaintenanceWindow[]; @@ -51,7 +51,7 @@ const getTimeToNextWindow = ( // Window is in the future if (start.isAfter(now)) { - return prettyMilliseconds(start.diff(now), { unitCount: 2, hideSeconds: true }); + return formatDuration(start.diff(now)); } return "N/A"; @@ -152,9 +152,7 @@ export const MaintenanceWindowTable = ({ id: "repeat", content: t("pages.maintenanceWindow.table.headers.repeat"), render: (row) => - row.repeat === 0 - ? t("common.labels.na") - : prettyMilliseconds(row.repeat, { verbose: true }), + row.repeat === 0 ? t("common.labels.na") : formatDuration(row.repeat), }, { id: "actions", diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 78bdc42a4..4112967ab 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -103,3 +103,20 @@ export const formatMs = (ms: number, hasSpace: boolean = true): string => { } return formatted; }; + +export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = true) => { + if (verbose) { + return prettyMilliseconds(ms, { verbose: true }); + } + + const formatted = prettyMilliseconds(ms, { + unitCount: 2, + secondsDecimalDigits: 0, + millisecondsDecimalDigits: 0, + }); + + if (hasSpace) { + return formatted.replace(/^(\d+)(\D+)$/, "$1 $2"); + } + return formatted; +}; From e3b0b33b15365b5d1a06fd16a269a5ea5b8cd660 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 13:00:58 -0700 Subject: [PATCH 4/6] replace duration in monitorstatus, remove dead import --- client/src/Components/design-elements/MonitorStatus.tsx | 6 ++---- client/src/Pages/Logs/components/Stats.tsx | 1 - 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index 2d53470f0..f353328c1 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -4,10 +4,10 @@ import Typography from "@mui/material/Typography"; import { PulseDot, Dot, StrategyBadge } from "@/Components/design-elements"; import { getStatusColor, formatUrl } from "@/Utils/MonitorUtils"; import { useTheme } from "@mui/material/styles"; -import prettyMilliseconds from "pretty-ms"; import { typographyLevels } from "@/Utils/Theme/Palette"; import useMediaQuery from "@mui/material/useMediaQuery"; import { LAYOUT } from "@/Utils/Theme/constants"; +import { formatDuration } from "@/Utils/TimeUtils"; export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { const theme = useTheme(); const isSmall = useMediaQuery(theme.breakpoints.down("md")); @@ -51,9 +51,7 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { {!isSmall && ( <> - - Checking every {prettyMilliseconds(monitor?.interval, { verbose: true })} - + Checking every {formatDuration(monitor?.interval)} )} diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index 884fa9c18..be67e72d4 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -2,7 +2,6 @@ import Box from "@mui/material/Box"; import { StatBox } from "@/Components/design-elements"; import prettyBytes from "pretty-bytes"; -import prettyMilliseconds from "pretty-ms"; import { useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import type { Diagnostics } from "@/Types/Diagnostics"; From e0122d376146146b46b0131a1145faec80dc6081 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 13:04:10 -0700 Subject: [PATCH 5/6] hoist props --- client/src/Pages/Logs/components/Stats.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/Pages/Logs/components/Stats.tsx b/client/src/Pages/Logs/components/Stats.tsx index be67e72d4..d76aa7443 100644 --- a/client/src/Pages/Logs/components/Stats.tsx +++ b/client/src/Pages/Logs/components/Stats.tsx @@ -33,10 +33,10 @@ export const Stats = ({ diagnostics }: StatsProps) => { return ( *": { width: "100% !important" }, }} > From d4782a97a30ab52d41f9086a2e2bf54a7e9d05a2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Fri, 31 Jul 2026 13:05:10 -0700 Subject: [PATCH 6/6] add missing verbose flag --- client/src/Components/design-elements/MonitorStatus.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/Components/design-elements/MonitorStatus.tsx b/client/src/Components/design-elements/MonitorStatus.tsx index f353328c1..cc0f8df5b 100644 --- a/client/src/Components/design-elements/MonitorStatus.tsx +++ b/client/src/Components/design-elements/MonitorStatus.tsx @@ -51,7 +51,9 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => { {!isSmall && ( <> - Checking every {formatDuration(monitor?.interval)} + + Checking every {formatDuration(monitor?.interval, true)} + )}