Requested by a user who found xx.xx% more trustworthy than xx.x% on their
status page. The presentational argument holds, but the stronger reason is
that one decimal was actively misleading.
Intl rounds to the displayed precision, so every value above 99.95% rendered
as "100.0%" — a claim of zero downtime for a period that contained real
outages. On a public status page that is the opposite of reassuring: the
number reads as flawless during a month with a visible incident.
One decimal also collapsed the range operators care about most. 99.9%,
99.95% and 99.99% are three very different commitments (8.7 hours, 4.4 hours
and 52 minutes of annual downtime) and the first rendered as 99.9% while the
other two were indistinguishable at 100.0%.
Everything flows through the shared formatter in FormatUtils, so this covers
both surfaces at once: the monitor list and diagnostics gauges in the web
app, and the uptime figure, chart cells and hover tooltip on status pages.
Infrastructure gauges are untouched — they format separately at zero
decimals, where CPU at "43.00%" would only add noise.
Server-side precision was already sufficient; the aggregation divides
without rounding, so no API change is needed.
Adds tests pinning the regression: 7 of the 10 fail against the previous
one-decimal formatter.
Note: this restores the client Vitest setup. PR #3844 introduced it, but the
config, scripts and logger tests were dropped when that branch merged into
develop, leaving the client with no test runner again.
Adds a regression suite for #3843 so the leak cannot return silently. The
client had no test runner, so this introduces Vitest, which reuses the
existing Vite resolution and the `@/` alias rather than needing a separate
transform pipeline. jsdom is required because the logger reads
window.location and navigator.userAgent.
The tests drive the exported `logger` rather than the private redaction
helpers, so they cover the wiring as well as the logic. That distinction
matters here: the first attempt at this fix redacted the JSON branch but
left the debug branch printing raw objects, a gap that testing the helper
in isolation would not have caught. Each case runs at both error and debug
level for the same reason.
Covered: the reported login payload, credentials carried on an AxiosError
(config.data and the Authorization header), non-JSON request bodies,
passwords on class instances, key-matching variants, and circular payloads.
One asserts non-sensitive context survives, so redaction cannot regress
into blanket suppression.
Verified by reverting the fix: 16 of 23 tests fail against the original
behaviour, including a pre-existing crash on circular payloads.
Follow-up to the initial redaction pass, addressing four defects:
Class instances bypassed redaction entirely. The prototype guard was meant
to skip FormData/Blob but caught every non-literal object, returning it by
reference — so a credential on a DTO or form-state instance was logged
verbatim, and the deep-copy contract was violated. The check is now an
explicit deny-list of opaque types; everything else is walked.
The raw Error was still logged by the debug branch. An AxiosError carries
config.data (the serialized request body) and config.headers.Authorization
(set for every request in ApiClient), so the password redacted from the
context was printed straight back out via the error object. Errors now go
through toSafeError(). Because Axios serializes the body to a string, which
key-based redaction cannot see into, it is parsed before redaction; a body
that is not JSON is replaced wholesale rather than logged raw.
The Blob guard was gated on `typeof FormData !== "undefined"`, so in an
environment with FormData but no Blob the instanceof check threw a
ReferenceError — from inside a catch block, turning a handled API error
into an unhandled crash. Each constructor is now resolved defensively.
Also widened the key list (cookie, jwt, privateKey, sessionId, csrf, pwd)
and dropped a redundant type annotation.
Verified with 18 assertions covering the reported payload, class instances,
cycles, shared siblings, axios error shapes, and a missing Blob global.
Failed requests logged the full request body to the browser console, so a
failing POST to /auth/login wrote the user's plaintext password where it
could be captured by error-reporting tools, screen shares, or support
sessions.
Redaction is applied centrally in the logger rather than at the call sites,
so every current and future caller is covered:
- createLogData() redacts context before it is serialized, covering the
JSON branches of error() and warn().
- The debug-level branches of error() and warn() printed the raw context
object directly, bypassing createLogData(); they now print the redacted
copy.
- debug() and info() redact their free-form data argument.
Keys are matched case-insensitively on a substring after stripping dashes
and underscores, so password, newPassword, X-Api-Key and Authorization are
all caught. The walk copies rather than mutates the caller's object, cuts
genuine cycles without mislabelling repeated siblings, and renders
FormData/Blob as a type marker instead of walking them.
Affected paths were POST (/auth/login, /auth/register) and PATCH/PUT, which
carry monitor secrets. Error-level logging is on by default, so this was
not limited to debug builds.
Fixes#3843
The preview framed the page public URL, which for a custom domain is cross
origin and blocked by the admin document CSP frame-src. Point the iframe at the
same-origin public path while the frame chrome still shows the public URL.
The form only enforced the length cap, so CSS with an external url() or @import
passed client validation and was rejected by the server. Add the same tokenizer
based check with the same error message so it is caught inline before submit.
- Add standard custom HTTP status codes (Cloudflare, AWS ELB, etc.) to both frontend and backend allowlists.
- Enforce strict validation of custom status codes using Zod sets on both sides, ensuring they match perfectly.
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.
Anonymous visitors to a public status page have no persisted Redux
state, so ThemedHeatmap and ThemedHistogram tooltips always rendered
timestamps in the hardcoded "America/Toronto" default — regardless of
the timezone the operator configured. Even on the same domain,
ThemedHeatmap and ThemedHistogram had no code path to consume the
operator-configured statusPage.timezone returned by the API.
- Add SYSTEM_TIMEZONE module constant and a small resolveTimezone()
helper to TimeUtils.ts. formatDateWithTz's timezone parameter is now
optional and defaults to the system tz, so callers don't need to
rebuild the prop -> browser -> UTC chain inline.
- Add a `timezone` field to StatusPageThemeContextValue so any themed
component on the public page can read the page-wide tz without prop
drilling (parallels how `theme` and `mode` are already exposed).
- Wire statusPage.timezone into StatusPageThemeProvider from
Pages/StatusPage/Status/index.tsx (both the public route and the
admin preview).
- ThemedHeatmap and ThemedHistogram now read tz from the context
instead of useSelector(state.ui.timezone) — that selector was
unreliable for anonymous visitors and removing it cuts a Redux
subscription on the public page.
- Replace the inline `data?.timezone || Intl.DateTimeFormat()...`
expression in useStatusPageForm.ts with resolveTimezone().
Closes#3628.
- Button.tsx: contained variant's hover now uses HOVER.DARKEN=0.06
instead of MUI's default 16%, which was visibly heavy on the green
primary buttons across the app. Also fixes a subtle bug where the
earlier hover sx merge order silently dropped the boxShadow:'none'
reset. Added a HOVER token group to constants.ts so the darken value
has a single source of truth.
- TextInput.tsx: forward the caller's sx to the wrapper Stack when a
fieldLabel is present (previously it was applied to the inner
TextField, so flex:1 etc. didn't propagate to the visible width).
Inner sx now also explicitly width:100% so wrapped inputs don't
collapse to content width when their parent is a flex row.
- FieldLabel.tsx: drop the hardcoded fontSize:'14px' and use
variant=body1 (=13px from theme) to match the rest of the app's
body typography.
The sidebar selected-icon colour was sharing primary.main, which read too dark in dark mode. Introduce palette.sidebar.accent (brand green in light mode, a lighter green in dark mode) and route NavItem through it so primary stays brand-green for buttons and the rest of the UI.
Annotate the hex literals used when callers omit theme colours so the intent (defensive MUI default match) is obvious instead of looking like arbitrary values.
- Introduce a reusable eyebrow Typography variant in the theme (13px, 500 weight, uppercase, 0.08em tracking) for section headings
- Apply it to ConfigBox titles in /settings and to "Job queue" / "Failed jobs" in /logs
- Drop the page header weight from 600 to 400 with tighter tracking so titles read lighter and more refined
Renames the primary palette token from blueBlueWave to brandGreen and points it at #13715B in both light and dark themes, so primary buttons, sidebar selection, slider rings, switch tracks etc. follow the brand instead of the old blue.
- Switch: 36x20 contained pill with 6px right padding so labels keep their distance even inside Stacks
- Radio: theme-level override, 20px ring with a fixed-size inner dot — checked state no longer animates a scale change that pushed labels around
- Slider: white thumb with primary-colour ring, 4px track, soft hover/active glow
- Drop the custom RadioInput icon overrides that bypassed the theme and caused the size jiggle
- MuiOutlinedInput hover override: was inheriting MUI's default
white outline in dark mode (too bright). Now uses an alpha-23%
white in dark and text.primary in light.
- TextInput: -webkit-autofill now repaints the input bg with
background.paper (instead of Chrome's harsh blue/yellow autofill
highlight), text in text.primary, with the standard 5000s-transition
trick to suppress the brief flash. Works in both modes.
## Changes
- Replace MUI default blue-tinted dark background with neutral grays
(#151518 for default, #1c1c21 for paper/cards)
- Remove decorative blue/purple SVG blob overlay from dark mode background
## Benefits
- Dark mode now has a clean, neutral gray appearance instead of a blue tint