Skip to main content

Changelog

0.32.1 — 2026-09-24​

(diff)

Bug Fixes​

  • Docker image: v0.32.0 shipped without its Docker images; this release publishes them. The v0.32.0 server image (ghcr.io/fclairamb/solidping) and sp image (ghcr.io/fclairamb/solidping/sp) both failed to build: the RDP client added in that release is vendored as a local Go module, and the Dockerfiles downloaded dependencies before copying it in, so go mod download stopped with third_party/grdp/go.mod: no such file or directory. The release binaries and sp downloads were published normally, but latest still pointed at v0.31.1, so anyone running the image did not get v0.32.0. v0.32.1 contains exactly the v0.32.0 features plus this fix. The Docker images were only ever built from a release tag, so no pull request exercised them; the dependency layer of both Dockerfiles is now built on every pull request, which makes this kind of break fail before release instead of after (#437)

0.32.0 — 2026-09-24​

(diff)

Features​

  • Checks: a check that fails intermittently, or answers far slower than usual, is no longer invisible. A production check with 7 failures in 113 probes (93.8% availability, one successful probe that took 11.1 s against a 453 ms baseline) produced zero incidents, zero notifications and zero history — every failure was followed by a success before the confirmation period elapsed, and nothing read response time as a signal at all. Degraded detection adds a statistical rule alongside the existing outage detector: it fires when M of the last N countable probes match a failure or a slow pattern (5 of 60 failures, or 3 of 6 slow probes over a threshold, by default — both configurable per check, in the check form's new "Degraded detection" section, with a one-click suggestion at roughly 2x the check's observed p95 latency), and resolves the same way. A degraded episode opens its own incident kind — visible in history, the timeline and notifications — but is deliberately not an outage: it never pages on-call, never cascades to dependents, never counts against availability, SLOs or uptime reports, and only reaches a status page if that page explicitly opts in. The chart shades the episode's span in amber rather than plotting isolated dots, and the notification wording says "degraded" and "currently up", never "down". Upgrading is silent: the feature ships off for every existing check and on for new ones, and while off, a dry run stamps only "this check would have been flagged degraded at …" with a banner and a deep link, so nothing starts paging on its own — enabling it is a deliberate, per-check decision (#429)
  • Checks: RDP checks can now prove a user can actually log on, not just that the listener answers the handshake. checkrdp used to stop at the pre-auth X.224 negotiation — proof the port is open and which security protocol it offers, nothing about whether a user can reach a desktop. A Windows server with a full disk, a broken profile service, an expired licence server or a hung logon script still read green. Optional username/password/domain fields now drive a full NLA (CredSSP + NTLMv2) logon through a vendored, cgo-free RDP client: after the handshake the checker waits for the screen to settle, can capture a screenshot of the desktop, and ends the session either by logging off (default) or disconnecting, leaving it running for a faster reattach next run. Authenticated runs get dedicated failure states (auth rejected, logon timeout, server-initiated disconnect) and a raised timeout ceiling (45s default, 90s max) for profile load. Because every authenticated run is a real interactive Windows logon — it can consume an RDS licence and, on single-session servers, disconnect a real logged-in user — the minimum period is floored at 15 minutes, and the docs call out using a dedicated monitoring account, never a person's. The same capability reaches js check scripts as a new rdp object mirroring browser: rdp.connect(), click/type/key (Unicode input events, so the target's keyboard layout doesn't matter), pixel/regionHash/waitForStable assertions, and explicit logoff()/disconnect() (#431)
  • Authentication: "Forgot password" no longer silently sends nothing to a user who signed up through SSO. A user with a nil password hash (Google, GitHub, Microsoft, SAML, OIDC, LDAP sign-in) hit the same generic "a reset link has been sent" response as everyone else, except no email ever arrived — a recurring support confusion, since the only alternative, the authenticated change-password endpoint, requires already knowing how to log in. Password reset now runs the same token flow (same rate limits, same 1h TTL) for SSO-only users, and the confirmation step already knew how to set a first password for a nil hash. The email itself now also names how the user normally signs in — "You usually sign in with Google" — so someone who forgot they use SSO learns that instead of just getting a new password. The HTTP response stays identical in every branch, so the API still never reveals whether an account uses SSO (#431)
  • Checks: a check's degraded-detection configuration now survives config-as-code export/import. The six degraded-detection fields — the failure/slow-probe thresholds and their windows, the slow-response threshold, and the enabled flag — were never carried by ExportCheck, UpsertCheckRequest or the import path, so a check customized away from the defaults silently reverted to them on an export/import round trip. The nullable numeric fields now export as the raw pointer value (nil omitted from the document, not the resolved default), matching the existing rule for TracerouteOnFailure: freezing today's effective default into every export would make an explicit opt-out indistinguishable from "never configured", and would re-apply a stale default forever after a re-import (#431)
  • Dashboard: the dashboard trades its stock-shadcn blue for a stronger "electric blue" identity. Primary actions now run a cyan-to-indigo gradient instead of a flat mid-blue fill, the same gradient extends to the page header's icon tile, and a faint glow sits at the top of every org page. The sidebar is now always dark navy in both light and dark mode rather than near-white, so the app is no longer one flat light plane, and a new hero variant of the KPI tile anchors one headline stat per page. The crimson --brand token is untouched and now reads as the one warm accent against blue chrome, instead of the theme's only accent. Every new primitive and pattern is documented on the design reference page (/d/orgs/<org>/design-reference), which stays the single source of truth for dash0 UI work (#431)
  • Status pages: the public status-page API can now skip the availability history and response-time chart a caller does not render. Both endpoints always returned every section — for a 200-resource page that includes up to 100 daily-availability points and 100 response-time points per resource, most of it unused by a TV wallboard or an integration that only wants status. A new include query parameter (e.g. ?include=availability or ?include=) narrows the response to just the sections asked for; omitting it returns the same payload as before, byte for byte (#429)
  • Checks: every check type's config shape is now published as a JSON Schema, both over the API and in each release. The shape of a check's config — which fields a kubernetes or oracle check accepts — used to live only in prose and in Go validator code, so no editor or CI linter could complete or validate a config-as-code manifest without reading the source. GET /api/v1/checks/schema lists every check type's schema and GET /api/v1/checks/schema/{type} serves one (public, no auth required — a schema describes what this build supports, not an organization's data); the full set also ships as check-config-schemas.tar.gz alongside each release for tooling that shouldn't have to reach a running instance first. The schemas are generated from the same structs the validators run on, so a schema can never drift from what the API actually accepts — and they are explicitly documentation, not a validator: sp checks validate remains the source of truth (#429)
  • deploy: SolidPing now has a one-click Coolify template. deploy/coolify/solidping.yaml imports directly as a "Docker Compose Empty" resource in Coolify — a single container on SQLite, pinned to the current release since Coolify does not support floating tags. Coolify's own template catalog still requires 1,000 GitHub stars to list it automatically, so this is available today for anyone who wants to import the file directly while that listing is pursued separately (#429)
  • Status pages: the second reader of a status page inside 15 seconds now costs the server nothing, and N readers arriving at once cost one computation. Every public read recomputed the page from scratch — sections, up to 200 per-resource lookups, availability buckets, the response-time seam, incidents, updates — so two wallboards, a README badge and a customer hitting refresh were four independent recomputations of one answer, queueing on the same connection pool. The computed view is now memoized in process for 15 seconds, keyed by page and payload shape, and concurrent misses collapse into a single computation through singleflight: the load shape of an incident, when everybody opens the page at once and the infrastructure behind it is already unhealthy, is now one slow render instead of fifty. Fifteen seconds is a quarter of the 60 s the HTTP directive already promises, so nothing a reader sees gets staler — and an operator's own edit is never delayed at all: every write that changes a page's public body (page and section and component CRUD, selector reconcile, logo and favicon, custom domain, incident publications including auto-publish and auto-resolve, status updates) evicts that page immediately, from a table in code that two tests keep honest. The access gate is unchanged and runs on every single request, ahead of the memo: a password page still 401s anybody without the cookie, warm memo or not, and taking a page private hides it that instant. Public responses also gained stale-while-revalidate=30, so the reader whose browser copy expired one second before the spike gets the old number immediately and the refresh happens in the background instead of blocking the render (#429)
  • Status pages: a TV wallboard now shows an outage within a second of loading, instead of waiting for the whole page payload. The board rendered nothing until the full public page arrived — on a 200-resource page with 90 days of history that is 3.2 MB and about 7 seconds, and the room stared at "Loading" the entire time. It read exactly one thing out of that expensive payload: the page-level uptime percentage, a mean over 7 or 90 days that cannot move between two polls. The board now makes three reads in the order a room needs them. The incident history is the fastest of them (about 10 ms) and paints first: the state, the open incidents, the recently-resolved strip and "days since the last incident" are on the wall while the page itself is still in flight, and its name, status counts and named failing checks fill in around them when it lands — no flash, no remount. The page read asks for neither optional section, so it carries the rollup, the counts and the per-check statuses and nothing else. The uptime number comes last, off the cheap summary endpoint, refreshed every five minutes instead of every 15-30 seconds; a slow or failing summary hides the number and never touches the rest of the board. One correctness fix comes with it: the open incidents and the incident history used to be read off two different payloads fetched seconds apart, so the headline's severity and the incident panel below it could describe two different instants — the board could name an incident in the panel that the headline knew nothing about. Both now come from the same response (#429)
  • Status pages: availability bars, badges, SLOs and the availability API are several seconds faster: the per-bucket numbers are now computed in the database. One helper answers every availability figure in the product — "per check, per bucket: how many probes, how many up, maintenance share, response-time sum/min/max, slow samples" — and it computed them by fetching every matching result row and folding them in Go. The aggregation job only writes an hour rollup once the hour has aged past the raw retention, so under the default 24 h the newest day of every check is always raw: a 200-resource public status page with a week of history read 267,449 rows, sorted them to disk (a 12.8 MB external merge), and shipped them all across the wire to produce 400 numbers — 3.1–3.4 s inside the request, most of it transfer and row scanning. The counters are now a GROUP BY per index side: 400 rows instead of 267,449, no sort, ~0.5 s. Nothing about the numbers changes — the Go accumulators stay in place as the reference semantics, the SQL mirrors them field for field, and a parity test per database engine folds the same deliberately nasty fixture both ways and requires identical results. Two safety row caps go away with the row path, and that is a correctness fix in its own right: they existed to bound how many rows the fold had to hold, and when one engaged it logged a warning and returned a silently wrong availability percentage to a public page (#429)
  • Status pages: a response-time point is now a p95 over its period everywhere on the chart, and the page reads a fraction of the rows it used to. The recent end of the chart — the probes newer than the newest hourly rollup — was plotted as individual probes. The server fetched every one of them (~1,300 per component on a 7-day page; 292,843 rows for a 200-component page) and then kept roughly one in ninety to fit the 100-point budget, so a single arbitrary probe stood in for ninety minutes of monitoring and its spike, or its absence of one, was not representative of anything. Meanwhile the hourly points next to it carried a real p95 over sixty probes. That seam is now computed in the database: one point per bin — 15 minutes on a 24 h page, 1 hour on any longer one, never coarser than the hourly rollups beside it — carrying a p95 over every probe in the bin plus the probe counts the tooltip already showed for rollup points. The whole chart is the same kind of number end to end, and the newest point is the open bin, which moves as probes land. The read shrinks by the same amount the plotting was throwing away: a query-plan test pins the seam at one row per (component, region, bin) — at most ~110 rows per component for a 26 h seam at 15-minute bins, against the ~8,640 the row-shaped read ships for the same window on the same data. That is expected to take a second or more off a large page's render; the row count is the part that is measured here, the wall-clock gain will depend on the deployment. The p95 is deliberately the same nearest-rank definition the aggregation job writes into duration_p95, pinned against the job's own helper by a test, because each seam point is eventually replaced by the hourly rollup covering the same probes — an interpolating percentile would have made the chart visibly step every time the aggregation job ran. One incidental fix: a component whose only recent row was the one-time "check created" marker no longer contributes a phantom point, since the statuses excluded from availability are dropped before binning (#429)
  • server: every HTTP response is now gzip-compressed — a 3.2 MB status page transfers as ~200 KB. Nothing in the stack compressed responses: the server ignored Accept-Encoding entirely, so a 200-resource public status page with a week of history shipped its full uncompressed JSON, and the status-page bundle's JS shipped uncompressed too — the difference between a TV wallboard loading in 7s on a fast link and 12s on a slower one. SolidPing now gzip-compresses JSON, HTML, CSS, JS, SVG and XML/Atom/YAML bodies over 1 KiB at the outermost handler, so this applies to every install with no operator action and no reverse proxy required — SP_SERVER_COMPRESSION (default true) is a one-line way out for an operator who'd rather spend the CPU at their own proxy. A proxy that also compresses is unaffected: a response that already carries Content-Encoding passes through untouched (#429)
  • CLI: the sp binary is a third of its former size: ~32 MB bare, ~12 MB gzipped, down from ~99 MB and ~31 MB. A config-validation CLI had no business being ~99 MB. It was linking every checker's implementation — Kubernetes client-go, Oracle's go-ora, the JS engine, gRPC, Docker, ClickHouse, Mongo, Kafka — because sp checks validate reaches the server's own validators through the checker registry, and Go links whole packages: asking for the config half dragged in the execution half with it. Each checker's config now lives in its own light config sub-package (the struct, its parsing, its key constants and its whole rule set, including the name/slug defaulting), and the CLI reaches them through a new config-only registry that links no protocol client. Nothing about validation changes: it is still the very same Go validators the live create/update path runs, still fully offline, and the server binary is unchanged. The .gz twins stay published alongside the bare files (#429)
  • CLI: every sp binary now ships with a gzip twin, cutting the download from ~99 MB to ~31 MB. The bare-binary naming kept download URLs stable, but bare is also uncompressed — and sp links the server's whole checker registry (k8s, Oracle, JS, gRPC, …), so every release asked a metered or slow connection for ~99 MB. Each release now also publishes sp_linux_amd64.gz and friends — the same version-free names with .gz appended, listed in sp-checksums.txt like the rest — so releases/latest/download/sp_linux_amd64.gz is the same stable contract at a third of the size; decompress with gunzip and the bare files remain the canonical, unpinned URLs. The install docs show the .gz route (#424)

0.31.1 — 2026-09-22​

(diff)

Bug Fixes​

  • CLI: the sp release downloads now have version-free names, and Windows gets a build. Every release published the CLI as sp_<version>_<os>_<arch>.tar.gz — a name that changes with every release — so a releases/latest/download/… URL (the natural way to fetch the current version in a script or CI job) could never work, and there was no Windows binary at all. The assets are now bare binaries named like the server's: sp-darwin-amd64, sp-darwin-arm64, sp-linux-amd64, sp-linux-arm64 and sp-windows-amd64.exe, with a single sp-checksums.txt. https://github.com/fclairamb/solidping/releases/latest/download/sp_linux_amd64 is now a stable contract no matter which version is latest, Windows users get a runnable .exe with no tar step, and the install docs and CI snippets are updated to match (#417)
  • Discord: pressing Acknowledge on a Discord alert now posts the acknowledgment into the incident's thread, not just the channel. The alert card in the channel was rewritten in place ("✅ #100 … acknowledged by @you") and the button-press handler also posted a follow-up — but into the alerts channel, never into the incident's thread, even though the comment that opened the thread and every later follow-up ("Incident resolved", …) land there. The acknowledgment notice now resolves the incident's thread from the same mapping the notification sender wrote when the alert was posted — un-archiving it first, the way a late resolve already had to — so the conversation about the incident carries its own acknowledgment. An incident with no thread (thread creation denied, or a DM destination) still gets the notice next to the alert, as before (#420)

0.31.0 — 2026-09-21​

(diff)

Features​

  • Status badges: status badges now react to hover — availability percentages per segment and per-bucket response times, no JavaScript required. The uptime-bar row's segments and the response-time-graph row's buckets each carry a hidden tooltip: hovering a bar segment dims it and shows its time range and availability percentage (Wed Jan 7: 99.8%), and hovering the graph shows a vertical band highlight, the bucket's average response time (Wed Jan 7 → 304ms) and a red marker dot pinned to the exact point on the line. Because this rides on CSS and <title> tooltips inside the SVG, no script is involved — but how much you get depends on the embedding: <object>, <iframe> and inline SVG give the full interaction in every browser, while Chrome and Safari render a plain <img> in a non-interactive static-image mode where hover never fires (Firefox still shows the tooltips). The badge builder's preview now defaults to the interactive <object> embed — with a switch to the plain image preview and a ready-to-paste interactive HTML snippet — so what you preview is what an interactive embed renders. A side effect: tooltips are not width-gated the way the printed in-bar percentages are, so 24h/30d/90d bars now expose every segment's percentage on hover even where the label never fit (#414)
  • Status pages: a section can now follow a check group through a real selector. The By-group membership mode gained a checkGroupUid picker in the section dialogs (name and slug asked first, like every other editor), the service validates that the group exists before saving, and a group deleted later empties the sections that followed it — the editor shows an amber warning for that state rather than a silently empty section. The picker's round trip is pinned by e2e (#414)
  • ICMP checks: burst sampling now accounts for itself truthfully. Packets are sent concurrently and replies collected asynchronously, so one lost reply no longer truncates the run and reports never-sent packets as lost; the burst carries its own execution budget (count × timeout + (count-1) × interval) instead of the per-packet timeout having to fit the whole run, records rtt_ms_jitter (standard deviation over the burst) alongside min/max/avg, widens the interval floor to 10 ms, and starts the burst reader before the first packet is sent so an early reply is never dropped (#414)
  • Status pages: the response-time chart plots time as time. The X axis is a real time axis bounded to the page's history window, so two adjacent points that are days apart no longer render as neighbors; region series with nothing in the window are dropped instead of drawn as empty lines. The new axis behavior is pinned by e2e (#414)

0.30.0 — 2026-09-21​

(diff)

Features​

  • admin: super-admins can now list and search every user on the instance. Answering "does alice@acme.com have an account, and which organizations is she in?" used to require guessing the organization first and looking from inside it; accounts belonging to no organization, or to several, were effectively invisible. A new Users tab in the server settings (super-admin only, read-only) searches and pages across the whole instance and shows each account's memberships, including empty ones. Searching % or _ matches those characters literally, and deleted users, deleted memberships and memberships in deleted organizations are all handled instead of leaking through (#406)
  • Discord: a person can now be paged on Discord. The bot could alert a channel but not a human: escalation steps targeting a person, operator notices and the account page's Test button all skipped Discord because there was no Discord contact type. A member can now bind a discord contact (via a Discord sign-in already on file, or a link round trip that mints no session), and on-call mentions, operator notices and per-check routing can reach their DMs. A member who has DMs closed falls through to their next notification route instead of the page being counted as delivered, on-call mentions fall back to a self-declared identity when no admin mapping exists, and Acknowledge pressed inside a DM resolves the right organization instead of answering "this server is not connected" (#406)
  • Discord: the legacy webhook transport is retired. The bot migration left both halves live: the dashboard still showed an empty Webhook URL field under the "install the bot" panel on instances that had the bot, and the sender still fell back to webhook delivery. The field is now hidden when the bot can be installed, the webhook sending path is gone — an integration with no destination fails loudly instead of silently, and the stored URL is display-only — and comments ingested through the bot now carry a "via Discord" badge on the timeline (#409)
  • Status pages: a section can now follow a check group. Until now the only way to put a group's checks on a status page one by one was to invent a label that mirrored the group and remember to set it on every new check. A dynamic section accepts a third membership mode, By group: every check in the group is listed on its own, and a check moved into the group later appears with nobody touching the page. Deleting the group empties the section instead of silently publishing stale members, and the editor shows an amber warning for that state rather than a neutrally empty section. The add/edit dialogs ask for the section's name and slug first again, with the always-visible membership legend carrying the discoverability (#411)
  • ICMP checks: burst sampling, made usable. An ICMP check can now send a burst of up to 600 packets spaced as little as 50 ms apart, and the burst now gets its own execution budget (count × timeout + (count-1) × interval) instead of the per-packet timeout having to fit the whole run. Packets are sent concurrently and replies collected asynchronously, so a burst takes (count-1) × interval + timeout whether or not packets are lost — previously one lost packet at a high timeout consumed the budget and truncated the burst, reporting packets that were never sent as lost and inflating packet loss. A new rtt_ms_jitter figure (standard deviation over the burst) is recorded alongside rtt_ms_min/max/avg (#411)
  • Dashboard: the ICMP burst settings are reachable from the form. count, interval, packet size and ttl were server-side only — a user measured one sample per 10 seconds, concluded the product could not do better, and left without ever learning the burst feature existed. They now live in a collapsed section of the ICMP check form with a one-line summary of what the burst does ("10 packets, 100ms apart, every run"); the interval control is disabled at a single packet, and values set through the API survive unrelated edits in the dashboard (#411)

0.29.2 — 2026-09-19​

(diff)

Bug Fixes​

  • Slack: escalation pages sent to Slack were being dropped silently, and the channel picker and test button had stopped working. The bot token had moved into the encrypted settings_private envelope, but almost every reader still looked at the public settings map only, so a freshly installed app worked until the next server restart and then appeared to lose its token. The worst case was an escalation step that pages someone over a Slack DM: it logged a line, returned zero sends, and the on-call person was never contacted. Reading the token is now one shared code path used by every consumer, the OAuth install no longer writes the token into public settings at all, and an organization with more than one Slack integration resolves to its default one instead of an arbitrary row. A genuinely uninstalled app now says "Slack app not installed for this organization" rather than "slack client not configured", which read like a problem with your instance (#402)
  • On-call: channel alerts now name the person who is actually being paged. The mention only ever fired when step 1 of the escalation policy happened to point at a schedule, so an organization whose step 1 was the Slack channel itself and whose schedule sat at step 2 got no mention at all. Escalations were worse than silent: they always named step 1, even when it was step 3 that fired and was paging somebody else. The notification now carries the step that actually fired and names that step's targets, falling back to the first step naming a person or a schedule. A member who signed in with Slack, or who added their own Slack contact under Account, is now mentioned without an admin having mapped them by hand, and Account -> Notifications shows them how they will appear. Handles are never resolved across workspaces: a contact is only used when its workspace matches the integration. The line now reads On call: @alice instead of alice - you are on call for this., which read like a message addressed to Alice rather than telling the channel who is on call (#402)
  • Discord: the bot no longer offers an install it cannot finish. Signing in with Discord needs three settings; the bot needs five. The install routes and the install button were gated on the login check, so a deployment holding only the login credentials advertised the bot anyway and sent anyone who clicked it into an OAuth round trip that could not complete. That is what a user hit in production. The routes are no longer mounted and the button is no longer rendered unless the bot is fully configured, and the server logs one warning at boot naming the settings that are missing. Signing in with Discord is unchanged (#402)
  • Dashboard: two controls that looked live and went nowhere. The Incidents count on a check's page now opens the incident list filtered to that check, and stays inert when the count is zero. The Slack member-mapping picker used to show "Pick a person..." on rows it had just labelled Matched, and offered an empty, fully interactive dropdown whenever the workspace member list failed to load. It now shows who each row is mapped to whether or not that list is available, reports why the list failed using the message the API actually returned, and disables itself instead of pretending it can be used (#402)

0.29.1 — 2026-09-17​

(diff)

Bug Fixes​

  • Documentation: the API reference is reachable at /docs/api, and its 275 endpoints are grouped by topic instead of being one flat alphabetical wall. The generated landing page lived at /docs/api/solidping-api, a slug of the spec title, so the obvious URL returned a 404, and so did the "API reference" link on the dashboard's API tokens page. It is now at /docs/api. The endpoint list is now 31 tag groups (Checks, Incidents, Status Pages, On-Call Schedules, and so on), each with its own landing page written from the tag's description. The nine multi-word tags read as "Maintenance Windows" rather than "MaintenanceWindows", in the reference sidebar and in the /openapi Swagger explorer alike. The License block rendered as an empty heading and now links to AGPL-3.0. Every operation page kept its URL, so existing links and bookmarks still work (#391)

0.29.0 — 2026-09-17​

(diff)

Features​

  • Dashboard: the left menu is grouped, and Organization is finally in it. Fourteen flat entries with no headings became twelve under four labels — Monitoring, Alerting, Public status, Administration — and the collapsed icon rail is unchanged. Organization had exactly one entry point in the whole app, a line in the avatar menu in the bottom-left corner, which is why finding it took people a while; it is now the first Administration entry for admins, and the avatar entry stays. "My pages" was a pun on paging sitting three rows under "Status Pages", where it can only read as "status pages that belong to me" — it is your own alert history, and it is now "My alerts". "Status Updates" is now "Updates & notices", because the shared "Status" prefix on two adjacent rows meaning unrelated things was the entire problem. Jobs is now visible only to super admins (the API is deliberately unchanged, so sp jobs keeps working for org admins). Discovery moved under Organization with redirects from the old URLs, badges moved under the check they belong to, and the org-wide Dependencies page is gone — check dependencies themselves are untouched, and are still set on the check form, shown on the check page, and still drive incident rollup (#387)
  • Checks: HTTP checks can assert on the response body as text, with an operator instead of another bespoke key. bodyAssertions takes eq, neq, contains, not_contains and regex, each with an optional ignoreCase, and the dashboard form finally exposes the matchers — the flat body_expect / body_pattern family existed in the backend but no UI reached them. This is the difference between checking a health endpoint properly and not: body_expect: HEALTHY matches "UNHEALTHY", so an ASP.NET Core service reporting itself unhealthy was reported as up. eq trims surrounding whitespace so a trailing newline does not break a check, ignoreCase folds at pattern-compile time so anchored regexes still behave, and it applies to JSONPath assertions too. The existing body_expect, body_reject, body_pattern and body_pattern_reject keys are untouched and keep working (#387)
  • Dashboard: "Publish on a status page" now reaches the page you already have. The button navigated straight to the create-a-new-page flow, so anyone who already had a status page was sent to make a second one. It now opens a dialog listing your pages and their sections, and it recognises a check that is already published — directly, through its group, or through a section's label rule — and says so instead of offering you a duplicate. Creating a new page is still one click away. The section-membership choice (manual, all checks, or by label) is also now the first thing you see when adding a section rather than a control below the name, since a section that picks up new checks automatically is the thing most people wanted and could not find. Manual stays the default: auto-publishing checks to a public page is a disclosure decision, not a convenience (#387)
  • MCP: the MCP endpoint answers an anonymous initialize, so directories can list the server. MCP directories probe a server by starting it and sending the initialize handshake; ours replied 401, which blocked a listing outright. Only initialize and notifications/initialized are anonymous, and they return the server name, version, protocol version and capability flags — all public facts, identical for every caller, with no session minted. tools/list, resources/list and every tools/call still require a token (#387)
  • Documentation: labels have a documentation page for the first time, which matters now that they decide what appears on a public status page. It covers the key/value rules, how filtering ANDs across pairs, the autocomplete endpoint, the recommended public=true opt-in for anything customer-facing, and setting labels from config-as-code and the CLI. Check groups and labels sound interchangeable and are not, so both pages now carry the same blunt comparison: a check belongs to one group and carries any number of labels. The status pages guide gained a "my new check isn't showing up" answer near the top instead of ninety lines down, and an explainer for page vs publication vs update (#387)
  • CLI: sp checks import --from uptime-kuma-db kuma.db migrates straight from a Kuma 2.x install. The importer shipped in v0.7.0 only reads the 1.x backup JSON, and Kuma 2.0 removed the screen that produced it — so anyone who had been running 2.x since its October 2025 release, which by now is most Kuma users, had no supported way in. sp now opens the SQLite database Kuma always keeps on disk, read-only and WAL-aware so a monitor added since the last checkpoint is not missed, and rebuilds the same JSON the server converter already understands from the monitor, tag and monitor_tag tables alone. It never touches heartbeat, notification, user or api_key — the database also holds notification secrets and password hashes the CLI has no reason to read, and running on the user's own machine means only the handful of KB the converter needs ever leaves it, not the multi-GB file itself. Kuma on MariaDB/MySQL has no kuma.db and is out of scope; pointing the command at such an instance's data directory names that explicitly rather than reporting a generic "not a SQLite database" (#382)
  • Checks: the rabbitmq check can now alert on memory and disk headroom before RabbitMQ's own watermarks trip. In management mode, the check already asked the broker whether its resource alarm was already active — up until the exact moment a publisher got blocked, with no earlier warning. It now also reads each node's memory and disk figures from GET /api/nodes and grades them against two new two-tier thresholds: memoryUsedWarning/memoryUsedCritical (a percentage of RabbitMQ's high watermark, or a byte size) and diskFreeWarning/diskFreeCritical (a byte size only — the management API exposes no total disk size to be a percentage of). In a cluster the worst node decides the check's status; a warning breach counts as up and opens no incident, a critical breach fails the check with a human-readable message naming the node and the number that tripped it. Metrics (memory/disk gauges, running/total node counts) are now recorded on every management-mode execution, threshold or not, so existing checks gain history for free. The dashboard form gained the missing Mode (AMQP/Management) and Management Port fields plus the four threshold inputs — management mode was previously API-only (#382)
  • Checks: JavaScript checks can run through an SSH tunnel. A js check can now carry tunnelCheckUid, the same way http/tcp/websocket and the rest already do: pick a bastion in the check's Advanced settings, or set the key directly via the API, sp, or a config-as-code document. http.*, http.session(), and the tcp/udp/websocket handles are dialed through the bastion — the target hostname is resolved on the far side, not by the worker — and a tunneled http.<method>() response now carries tunneled: true. Two things are deliberately refused rather than silently run from the worker's own network: a sub-check of a type that itself cannot be tunneled (solidping.udp(...), solidping.icmp(...), solidping.dns(...), …), and browser.open(), since Chrome has its own network stack and cannot be routed through the tunnel (#382)
  • Checks: JavaScript checks can open TCP, UDP and WebSocket connections and drive them step by step. Three new globals — tcp.connect(), udp.open() and websocket.connect() — hand a script a real connection instead of a one-shot probe: connect once, write, read, decide in JavaScript what to send next, write again, close. Until now a js check could ask "is the port open" and "does one payload get one expected reply", and nothing else, so an entire class of target was out of reach for every check type SolidPing ships: a Redis AUTH followed by PING, where the second message only makes sense if the reply to the first was read and understood; a challenge-response handshake whose answer has to be computed from the greeting; a length-prefixed binary protocol where how many bytes to read next is in the bytes already read; a WebSocket feed where the proof the backend is alive is the one event after the two dozen that were not it. Wrapping such a target in an HTTP endpoint is not monitoring the target, and adding steps to a tcp check cannot branch on a reply — which is the whole reason a script is involved. Every call blocks and returns a value, exactly like http.* and sleep: there are no events, callbacks or promises to reason about. Reads accumulate until a delimiter, to an exact byte count, until an RE2 pattern matches, or take the next chunk as it arrives; bytes cross into the script as text or hex, since a JavaScript string cannot carry raw binary. TLS is one option away, with tlsVerify and SNI, and the connection reports the same version and cipher-suite strings a tcp check does. As with the browser global, the target's failures come back as values — refused (with a reachability class), unresolvable, a TLS failure, a rejected WebSocket handshake, a peer that closed, a per-call timeout carrying the partial data read so far, which is very often the whole diagnostic — so the script keeps the right to decide what counts as down. Only a transport the operator disabled, or the check's own deadline expiring mid-call, throws; the latter reports timeout and everything the script opened is disposed on the way out. Five connections per execution, each spending one unit of the existing 20-call budget, 200 socket actions in a budget of their own, and one shared 1 MB read ceiling (#382)
  • Checks: tcp and udp checks can now send a real payload and wait for the reply they expect. Completing a handshake proves a firewall forwards the port, not that the service behind it works — and the half of this that existed in the backend was invisible, substring-only, single-read and text-only. All four are fixed. send_encoding / expect_encoding take text (the default, so nothing stored changes meaning), escaped (\r \n \t \0 \\ \xNN — the only way a form field can express a CRLF) or hex, so a DNS query, an NTP request or an SNMP GET can be configured as bytes rather than approximated as text. A new expect_pattern takes an RE2 regex, compiled at save time so a typo is a VALIDATION_ERROR rather than a check that errors forever; expect_data keeps its exact substring meaning rather than being silently reinterpreted, and both apply when both are set. The reply is now read until it matches instead of once, so a banner written in two write()s, an answer that straddles a segment boundary, a greeting that arrives before the answer, or a UDP reply split across two datagrams all match — where before they failed nondeterministically, flapping with the network. Matching runs on the full accumulated reply (4 KB cap) instead of the 1 KB copy kept for the output field, which is a plain bug: an expected string arriving after byte 1024 could never match. A binary reply renders as \xNN escapes instead of the ????? encoding/json made of it. Silence behind an open port is now Timeout with no matching reply within 10s (0 bytes received) rather than a generic Down, because "port open, service mute" is the one thing this check exists to catch. And timeout finally means what it says: dial, TLS handshake, write and the wait for the reply share one deadline, where a timeout: 10s check could previously run for close to 30s. The dashboard form for both types rendered host and port only, so none of this was reachable by a user; it now has a Payload & reply section with the encodings. The two UDP samples dialed 8.8.8.8:53 and pool.ntp.org:123 without sending a byte — which cannot fail short of an ICMP port-unreachable — and now send a real DNS query and a real NTP client request and assert the answers (#382)

Bug Fixes​

  • Checks: a JavaScript check that ran out of time now reports timeout instead of an error. A script still blocked in a socket read when the check's own budget expired could surface as script error: GoError: context deadline exceeded, because the socket layer stops on the clock while the result was classified on the context's timer — and Go delivers timers late under load, so the two disagreed. Both now use the same test (#387)
  • TLS edge: the local-connection counter is no longer bumped after the connection is handed off, so a caller that reads the metric straight after seeing a response can no longer observe a stale zero. The forwarding path already recorded its decision before any bytes moved; both paths now agree (#387)
  • Docker image: the published image now persists its data with zero env vars. docker run -p 4000:4000 -v solidping-data:/data ghcr.io/fclairamb/solidping booted but kept nothing: the final stage had no ENV or VOLUME, so the SQLite database defaulted to /app and uploads (org logos, status-page assets, screenshots) to /app/data/files — both outside the mounted volume, both gone on the next docker run. The image now sets SP_DB_TYPE=sqlite, SP_DB_DIR=/data and SP_FILESTORAGE_LOCAL_ROOT=/data/files by default and declares VOLUME /data, pre-seeded and owned by the nonroot user (65532:65532) so a fresh named volume is writable on first run. The old -e flags keep working as overrides — Postgres users still set SP_DB_TYPE=postgres and SP_DB_URL (#382)
  • Heartbeat checks: an accepted UDP heartbeat beat is now answered with OK\n (3 bytes), matching what TCP has always sent. UDP replied with a bare 2-byte OK, so the two transports answered the same accepted beat with different bytes on the wire. If anything on your side matched the UDP reply on an exact 2-byte length or an exact OK (no trailing newline), loosen that comparison — it needs to accept OK\n now. The amplification guard (never more reply bytes than the datagram that triggered it) and the silence-on-failure behavior are unchanged (#382)

0.28.2 — 2026-09-14​

(diff)

Bug Fixes​

  • the log output format is now selectable with SP_LOG_FORMAT. The server hard-coded slog's text handler, so every deployment emitted logfmt and there was no way to ask for anything else. Behind a log collector that is a real loss: the whole record arrives as one opaque string, so level, msg and every structured attribute stay buried in the log body instead of becoming queryable fields. json now emits one object per record for collectors that parse stdout, pretty selects the colourized handler, and text (or its alias logfmt) keeps the previous behaviour and remains the default — so this changes nothing for anyone who does not set the variable. An empty or unrecognized value falls back to text: a typo must not cost an operator their logs. Enabling OTel log export would previously have ignored the choice silently, because that fanout path carried its own second copy of the text handler; both paths now go through one handler constructor (#374)
  • Dashboard: a click could silently do nothing in the moment just after a dialog closed. Radix keeps a dismissed modal mounted until its exit animation finishes, and for those ~200ms the full-screen overlay is still the hit-test target for the entire page while the layer that closed it still swallows a re-open. One flow landed in that window every single time: a mutation dialog closes at the same instant its list refetch resolves, so the row that was being waited for appears — looking completely interactive — while the modal that is visually gone still owns the pointer. Clicking a row action right then did nothing at all, with no feedback; clicking again worked. That reads as the UI feeling janky rather than as a bug, which is how it survived unnoticed. Dismissed modals now unmount synchronously, so the window no longer exists — shortening the animation would only have made it rarer. Enter animations are untouched, and the fix covers every dialog, alert dialog and sheet in the dashboard rather than the one page the symptom was found on (#375)
  • Checks: the docker check and container discovery now need Docker API 1.40 or newer — Docker 19.03, released in 2019, up from the previous floor of 1.24. This is a consequence of moving onto the split Go client Docker 29 replaced its own with, which was the only way off four unpatched security advisories in the retired module; API version negotiation still runs, so Podman and every currently-supported daemon are unaffected, and only a pre-2019 daemon would stop being reachable. Behaviour is otherwise unchanged: same connection options, same check outputs (#373)

0.28.1 — 2026-09-13​

(diff)

Bug Fixes​

  • ci: v0.28.0 published no sp image, despite its changelog promising one. The Publish the sp CLI job asked buildx for linux/amd64,linux/arm64 without ever setting Buildx up, so it ran on the default docker driver — which cannot build a multi-arch image at all — and failed with Multi-platform build is not supported for the docker driver. The four release archives and the main server image were unaffected and did publish; only ghcr.io/fclairamb/solidping/sp was missing. Dockerfile.sp's builder stage is now also pinned to --platform=$BUILDPLATFORM: it already cross-compiles via TARGETOS/TARGETARCH, so without that pin the arm64 target ran go mod download and the compile under QEMU emulation to produce exactly the same binary, minutes slower (#371)
  • CLI: sp --version reported 1.0.0 on every build ever shipped. cmd/sp hard-coded that string and never imported internal/version, so all four -X …internal/version.Version= ldflags the release workflow passes were inert — the published v0.28.0 archives report 1.0.0 too. It now reports the stamped version, and dev when built without one (#371)

0.28.0 — 2026-09-13​

(diff)

Features​

  • Checks: a js check can now drive a real browser. A new synchronous browser global — goto, fill, click, press, waitFor, text, evaluate, url, cookies, screenshot — backed by the same headless Chrome the browser check already uses. Until now the two check types split the one workflow every synthetic-monitoring buyer asks for and neither could complete it: the browser check loads a page and matches a keyword but cannot click or type, and the js check has the logic, secrets and HTTP client but no DOM, so a JavaScript-driven login was out of reach for both. A script can now log in through the real form, wait for the app to render, and hand the browser's cookies to http.get for the authenticated call. One page per execution, 100 browser actions, and evaluate as the escape hatch — deliberately not Playwright, and no Node runtime. Target-side failures return { ok: false, error } so a login button that never renders reads as down; only infrastructure failures throw, so "our Chrome sidecar is down" stays error and does not page anyone. A script that opens a browser inherits the browser check's 1m period floor (#365)
  • tests: the Postgres-backed Go test layer now runs on every pull request. Both make test and CI invoked go test -short, under which 72 files across 21 packages self-skip — every *_postgres_test.go included — so the only database CI had ever exercised was SQLite. That is exactly how an org-parameters key pattern shipped that returned HTTP 500 on Postgres through three clean review rounds. A new backend-postgres job runs the layer non-short with -p 1, and SP_TEST_REQUIRE_POSTGRES=1 turns "embedded Postgres would not start" into a failure instead of a silent skip, because a job that can be green while proving nothing reproduces the original bug. make test stays fast and -short; the live-network and Docker suites move behind a slowtests tag on a nightly (#365)
  • Checks: POST /orgs/:org/checks/validate now accepts a whole config-as-code document, JSON or YAML, and answers with every problem it can find rather than the first. Each issue carries a stable machine code — REGION_FORMAT, UNKNOWN_TYPE, INLINED_CREDENTIAL, DUPLICATE_SLUG, UNRESOLVED_SECRET_REF and a dozen more, published as a closed set — so a CI job can branch on the code and allow-list the classes it accepts instead of pattern-matching English. The route is member-level for a document: validating writes nothing, and a pipeline that only asks "is this file valid?" should not need a token that can delete checks. Needing one is the structural reason third parties kept their own re-implementations of these rules, and why those drifted — one organization's validator reported 197 problems on that organization's own export, 183 of them a region spelling and 12 of them check types the validator predated. The single-check form of the same route is unchanged and keeps the write floor it always had; ?plan=true, which returns the reconcile plan alongside the issues, needs admin. /import, /apply and /export remain admin-only
  • Checks: a dry run can finally prove that nothing would change. import/apply (and the "migrate from…" converters) now report an unchanged action and counter per check: created=0 updated=0 deleted=0 unmanaged=0 with N unchanged is the machine-readable "this file matches the instance". Until now every matched slug counted as an update whether or not anything moved, so re-importing a file that was byte-for-byte the current export answered created=1 updated=482 — the one question config-as-code exists to answer had no answer short of a client-side diff, and every tool that built one drifted from the server. An update entry now also names the fields that move, as changes: [{field, from, to}], with secrets and any ${param:…}/${env:…} reference masked so a plan pasted into a ticket never publishes a credential. The comparison is made on the normalized effective configuration and through the exporter's own code, so the folded @paris and the long @acme/paris are the same region, expectedStatus and expectedStatusCodes are reconciled, and document defaults never count as a change. Two honest limits are reported rather than glossed: a document that inlines a secret is shown as a masked update, because the stored value lives in an encrypted column a dry run must not open; and escalationThreshold, which the export carries but no write request accepts, is reported as a difference with a warning saying it cannot be applied, rather than being called unchanged for a file that genuinely differs
  • CLI: sp is downloadable. Every release now publishes prebuilt binaries for macOS and Linux on Intel and ARM, with checksums, plus a ghcr.io/fclairamb/solidping/sp image carrying the CLI alone. sp checks validate config.yaml is the validator for a SolidPing config file: it runs the server's own document rules offline — no token, no network — so it cannot fall behind the server the way a re-implementation must. Release v0.27.1 published zero assets, so a GitHub Actions job could not obtain it at all, which is precisely why third parties kept maintaining Python validators instead
  • CLI: sp checks diff now asks the server for the reconcile plan instead of text-diffing two documents, so it prints one row per check — create, update with the fields that move, unchanged, delete, unmanaged — and its exit code reflects the server's own comparison rather than a client-side normalizer. The 0 / 1 / 2+ contract is unchanged. --text renders the old textual diff, and is also what happens automatically when the caller cannot compute a plan (plans are admin-only)
  • server: campaign links under /demo carry utm_source (#363)

Bug Fixes​

  • Checks: a browser check's screenshot never reached the incident. The capture was taken at quality 90, which makes chromedp emit JPEG, and was carried in a field named PNG; the attachment store sniffs the bytes rather than trusting its caller, so it refused every one of them with screenshot attachments must be image/png. The feature was not mislabelled, it was silently dead — no screenshot has ever been stored. Captures are now WebP, the format travels with the bytes instead of being assumed, the store accepts PNG/JPEG/WebP and still fails closed on anything else, and the stored filename takes the extension of what was actually sniffed. That last part also fixes a second defect found alongside it: every deported-agent upload, traceroute JSON included, was being stored as .png. Because the store always failed closed, no mislabelled row can exist and there is nothing to migrate (#365)
  • Checks: an unrelated edit to a check with map-shaped secrets no longer fails validation. The placeholder injected for a secret the server cannot see was always a plain string, so renaming a check — or bumping its timeout — on a region-sealed check carrying secretHeaders (HTTP), secretMetadata (gRPC) or secrets (JS) was rejected with a VALIDATION_ERROR naming a field the request never touched, and no edit the operator could make would satisfy it. The same defect broke dry-running an organization's own export of an ordinary encrypted HTTP check. The placeholder is now shaped from the checker's own config type rather than a list of key names, so a fourth map-shaped secret field cannot reintroduce it (#365)
  • system: PUT /system/parameters/:key validates the key instead of letting Postgres answer. An uppercase letter, a space, a slash or a quote reached a CHECK constraint and surfaced as a raw 500 INTERNAL_ERROR quoting SQLSTATE 23514 — and on SQLite, which carries no such constraint, the very same request returned 200 and stored the row, so the bug was invisible to anyone developing on the default database. The key is now validated in the application layer, so both engines answer identically: 400 with VALIDATION_ERROR and the field key, matching what the org-scoped parameters route has always returned (#365)
  • CLI: sp checks validate --output json changed shape. Each issue was previously serialized with Go field names — {"Where": …, "Message": …} — and is now {"slug", "field", "code", "message"}, matching the new POST /checks/validate document response and adding the stable code a CI job branches on. Anything parsing the old keys must be updated; the text output gains the code and the field name on each line
  • Checks: creating (or importing) a check with no period now stores that type's own default instead of a flat 1 minute regardless of type. ssl, domain, dnsbl, js and browser each declare their own minimum interval (1h, 6h, 15m, 30s, 1m) and a default above it (6h, 24h, 1h, 1m, 5m); a create that omitted period for one of these used to land below the type's own floor — a value the same server would refuse if you had typed it explicitly — and so GET /checks/export produced a document that POST /checks/import rejected with period for ssl checks must be at least 1h. POST /checks/validate now resolves the same effective period as POST /checks, so a validate response can no longer describe a different check than what create would actually write. Existing rows are backfilled: an ssl/domain/dnsbl check still at the flat 1-minute value nobody asked for is raised to its type's own default; any other below-floor period — one an operator or an earlier import genuinely set — is left exactly as it is
  • Checks: a secrets: stripped export no longer carries a live ingest token. An email check's only config key is token — the local part of its inbound address, <token>@<domain> — and whoever knows it can mail that address and mark the check up. It is deliberately not a stored secret (inbound mail is matched by querying that column), but the exporter stripped only stored secrets, so the 48-character token was written verbatim into the exported document, and a second time on any smtp check whose delivery_to pointed at that inbox. The same applied to a heartbeat check's ping token. Export redaction is now its own per-check-type declaration — "public at rest, never exported" — and nothing is lost on the way back: re-importing a document that omits one of these values keeps the value the check already had, and an omitted delivery_to is rebuilt from the delivery_check_uid the document still carries. If you have committed an export produced by an earlier version, treat the tokens in it as disclosed; there is no rotation endpoint for email checks yet, so the way to replace one today is to recreate the check
  • Checks: a check can no longer end up with a blank name. POST /checks and PATCH /checks/:uid accepted "name": "" because the validation treated an empty string as a value, and a check type that derives no name of its own (tcp, udp, icmp) stored no name at all. Either way the exporter omitted the name key and the resulting document failed its own import with missing required key 'name' — a file the server produced that the server would not read back. A blank name is now refused with a VALIDATION_ERROR on the name field, a check created from a target alone is named after its slug (which is what the dashboard already displayed for it), and a migration fills in the existing rows. Operators upgrading a development database that already ran an earlier draft of the v0.28.0 migration must RESET it — SP_DB_RESET=true, or delete the SQLite file — and must not run solidping migrate repair: repair records the new checksum without running anything, which would leave the nameless checks in place while every later check reported the migration as applied
  • Checks: importing checks is honest about what it did. Three defects in POST /orgs/:org/checks/import lined up to produce the worst possible outcome on one real 47-check import: a dry run that said "all good", a real run that said "nothing happened", and an organization that silently gained 47 half-configured checks. All three are fixed. ?dryRun=true now validates everything the real run validates — label keys and values, the check type's own config rules, regions, period and alerting bounds, and your plan's check quota counted across the whole document — instead of stopping as soon as it had decided which entries were new. It still writes nothing, and it now returns the same created / updated / errors the real run would, with the same per-entry messages. Where a dry run provably cannot reproduce something (a concurrent create claiming a slug before you press go; a check whose stored config holds encrypted or region-sealed credentials, whose config the real update validates merged with those stored secrets), the response says so in a new caveats array rather than leaving you to find out. A failed entry no longer leaves a check behind. A check is written before its labels are; when anything after that insert failed, the row stayed, was reported in errors, and was not counted — so the response read created: 0 while 47 checks existed. The check is now removed when it cannot be completed, its slug is released so re-running the corrected document works, and no check.created event is emitted for it. In the one case where the cleanup itself fails, the entry is reported with state: "created-incomplete" naming the slug that really does exist — never silently as zero. The response's long-declared-but-never-set skipped field now has a meaning too: entries whose dependsOn edges were not applied
  • Checks: there is now one label key rule, and every layer agrees on it: lowercase, starting with a letter, 3 to 51 characters, letters, digits and hyphens — ^[a-z][a-z0-9-]{2,50}$ — with values that are non-empty and at most 200 characters. It is the rule PostgreSQL, the dashboard and status-page selectors have always enforced. The import and manifest validator used a laxer one of its own that accepted two-character keys, leading digits and dots, so a document with a key like os or k8s.cluster passed validation and then failed at write time with a raw database error: failed to create label: failed to create label: ERROR: … violates check constraint "labels_key_check" (SQLSTATE=23514). Those keys are now refused up front, by a message naming the key and the rule, as a 400 VALIDATION_ERROR on POST /checks and PATCH /checks/:uid and as a per-entry error on import — and no database wording reaches you under any circumstances. This is a behaviour change for SQLite deployments, which until now had no such constraint and accepted keys PostgreSQL could never store. A migration brings SQLite onto the same rule; any existing label whose key does not match it is dropped, because it is a label PostgreSQL could not hold and nothing will create again. If you author label keys through the dashboard you are unaffected — it has always enforced this rule
  • Checks: the reserved config-as-code label changed from solidping.io/managed to solidping-managed. That key contains a dot and a slash, which the label key rule above has never permitted, so POST /checks/apply and every "migrate from…" importer — Better Stack, Gatus, Uptime Kuma, UptimeRobot — were in fact broken on PostgreSQL and only appeared to work on SQLite. Existing SQLite installations have their labels renamed by the migration, so a manifest keeps its managed scope across the upgrade and nothing is adopted or deleted by surprise. If you filter or search checks on solidping.io/managed, switch to solidping-managed

0.27.1 — 2026-09-09​

(diff)

Features​

  • Dashboard: the dashboard now lives at /d and public status pages at /s. The old addresses carried a 0 that only ever meant "the rewrite, not the original" — noise in every URL anyone saw, typed, read down a phone or pasted into a chat, and eight characters of it in a status-page link a customer's readers are meant to bookmark. solidping.io/dash0/orgs/acme/checks is now solidping.io/d/orgs/acme/checks, and status.acme.com/status0/acme/main is status.acme.com/s/acme/main. Nothing you have already sent stops working: /dash0 and /status0 answer a permanent redirect onto the new addresses, keeping the rest of the path and the query string exactly as they were, and they will keep doing so — there is no sunset date. That covers notification emails sent months ago, bookmarks, links in Slack and Teams, the marketing site, and search-engine results. On a customer's own status-page domain the same redirect applies to /status0, while both /dash0 and /d are refused there, because a status page must never walk its readers into the SolidPing dashboard. Browser notifications need one extra step and take it themselves: a push subscription is tied to the address the service worker was registered under, so the dashboard retires the old registration on first load and re-subscribes, replacing the stored subscription rather than leaving a dead one behind
  • Authentication: the sign-up form now offers every sign-in provider the login page does. On the backend there has never been a separate "sign up with Google" — the first time a provider hands back an identity we have not seen, the account is created there and then, so "Continue with GitHub" on a first visit is registration. The only place to click it, though, was a page headed "Sign in", reachable from a small "Already have an account?" link at the bottom of the sign-up form — so a visitor arriving from the marketing site either worked that out or typed a password they never wanted. The same brand-iconed buttons now sit above the name/email/password fields on the sign-up page. Two deliberate differences from the login page: no "last used" shortcut, because someone on the sign-up page is telling you they are new; and no passkey button, because enrolling a passkey needs an account to attach it to
  • Dashboard: the organization members list now shows when each member was last seen, and whether it was a person or a credential. The table previously showed only a Joined date, which says nothing about whether someone still uses SolidPing — the question an admin actually has before reclaiming a seat, chasing an unverified paging contact, or working out whose automation is still running. The new Last seen column shows the more recent of two separately tracked signals, with a small icon for which one it was: a dashboard session, or an API credential (a personal access token, or an OAuth grant used by the CLI or an MCP client). They are kept apart on purpose. Merged into one number, a departed colleague whose nightly cron job still runs reads as simply "active"; kept apart, they read as what they are — "no dashboard since March, token used last night". A token that was minted and never used does not count as access, and a member who has never signed in and holds no token reads "Never" rather than showing an empty cell
  • Dashboard: on the super-admin activation funnel, an organization's name is now a link straight to that organization's audit log, instead of plain text you had to copy and paste a slug out of

Bug Fixes​

  • Reports: a brand-new organization is no longer emailed a weekly uptime report covering a week that closed before it existed. One account received a report 57 minutes after signing up: no data, "All checks (0)", for a week that had ended two days before the organization was created. Two separate causes, both in the scheduled sweep rather than in the report builder. The sweep mailed every recipient without first asking whether the schedule's scope resolved to any checks at all, so an organization with none — or a schedule pinned to an empty group — was mailed "no data" every period. And because a new schedule has no record of a previous run, the first period it claimed was simply whichever one had just closed, which for a new account can predate the account. Both cases are now skipped, and deliberately skipped after the period is marked as run, so a suppressed period is consumed rather than re-evaluated every hour for the life of the schedule. Asking for a test send still delivers the report, empty or not — that is a deliberate request to see what it looks like

Miscellaneous Chores​

  • the legacy dashboard application has been removed. A second, long-dead copy of the dashboard was still compiled into every build and still served as the fallback for any address that matched nothing else — so a typo'd URL, or a link to a page that no longer exists, quietly rendered the old dashboard's shell and answered 200 to search engines rather than saying "not found". Unmatched addresses now answer a plain 404. Builds are one stage shorter as a result
  • Dashboard: the hosted dashboard now records session replays, fully masked, to answer one question the event stream cannot: where a first-run user stalls before creating their first check. Every replay is captured with all text and all input values masked, so what is stored is layout, scrolling, cursor movement and which control was clicked — never a check name, a monitored URL, an incident, or anything typed. That keeps replay inside the same promise the rest of the analytics configuration makes: SolidPing learns how its interface is used, never what a customer monitors. As before, none of this runs unless analytics is configured at all, which a self-hosted installation has to opt into

0.27.0 — 2026-09-08​

(diff)

Features​

  • Live demo: the live demo now has an address you can say out loud: https://solidping.io/demo. The canonical link was https://solidping.io/dash0/login?demo=true — fine to click, poor to print, to type, or to read down a phone, and one wrong character (/dash0/login/?demo, say) quietly dropped the visitor on an ordinary login form with no sign that anything had been missed. /demo itself used to be nothing at all: it matched no route and fell through to the dashboard's catch-all, which answered 200 with an application shell that had no idea what had been asked for. It now redirects to that same canonical address, so every existing rule about the flag — which organization you land in, what happens when you already hold a session — applies unchanged. The shortcut exists only while the demo is switched on (SP_DEMO_ENABLED): a self-hosted install without one behaves exactly as it did before, rather than redirecting into a login page that then shows an ordinary form. The redirect is deliberately temporary rather than permanent, so switching the demo off takes effect on the very next request instead of lingering in visitors' browser caches. On a customer's own status-page domain /demo is refused, for the same reason /dash0 already is — a status page must never bounce its readers into the SolidPing dashboard (#353)

0.26.1 — 2026-09-08​

(diff)

Bug Fixes​

  • Dashboard: landing on an organization you cannot use now takes you to one you can, instead of a dead end. Following a link, a bookmark or an old email to an organization you are not a member of used to answer 403 on every request and show "Permission Denied" — whose only button linked back to the same organization you could not reach. You are now sent to your own organization (the one this browser used last, else your first membership) with a short note saying so, and to the "no organization" screen only if you genuinely have none. Administrators with cross-organization access are unaffected.
  • Live demo: entering the live demo from another organization's login page no longer strands you in that organization. The "Try the live demo" button raced its own sign-in — whichever navigation committed last won, and the losing one left you on an organization the demo account is not a member of, staring at Permission Denied. A returning visitor who still held a demo session hit the same wall just by opening an ordinary login link, with no race involved.
  • Authentication: signing in with a two-factor code now returns the same information as every other way of signing in. It was the one login path that omitted the caller's organization list, which briefly read as "this account belongs to no organization" and could flash the no-organization screen on the way to the dashboard. (#346)

0.26.0 — 2026-09-08​

(diff)

Features​

  • Authentication: keep the campaign that produced a signup (#342)

Bug Fixes​

  • Live demo: the live demo's deep link now works from every address anyone would naturally write, and wins against a session you already have. ?demo=true was only honoured on an organization-scoped login page (/dash0/orgs/default/login?demo=true) — the link that reads naturally, https://solidping.io/dash0/login?demo=true, dropped the flag and landed on an ordinary login form, as did /dash0/?demo=true and /dash0/orgs/<anything>?demo=true. All four now enter the demo on load, 1 and true alike, whatever organization the path happens to name. And the flag now outranks an existing session: following a demo link while signed into your own organization used to leave you in your dashboard, and following it while already in the demo dropped you on an organization the demo account is not a member of — a permission-denied page. Now the first re-enters the demo (your previous session is replaced, which is what the link asks for) and the second simply takes you there without signing in a second time. The canonical link is published in the documentation (Try the live demo)
  • Checks: saving a check no longer rewrites its notification channels when you did not change them. Every save issued a "replace the bindings" call carrying exactly what was already stored — invisible for most people, but in the live demo that route is deliberately closed, so the no-op write was refused and took the whole edit down with it: renaming a check you had just created showed a red "this is the shared read-only live demo" toast and left you stuck on the form, even though the rename had gone through. The write now happens only when the selection actually differs, and a demo visitor is no longer offered the notification and dependency pickers they would only be refused. Editing a check you created in the demo now behaves like editing any other check
  • Live demo: the shared live demo's write refusal is now a single, localized message instead of three different costumes of the same English sentence. It used to surface as an untranslated blue toast on some pages, a second red banner underneath it on the check form, and a separate red toast on the status-page section dialog — and its wording ("editing your own checks is allowed") contradicted what a visitor saw when the server actually refused for ownership of a seeded check. There is now exactly one toast, translated into the dashboard's language, and the wording covers both cases. The check edit page also no longer lets a visitor fill out a form for a check they cannot save — it shows the same explanation and a Clone button, the way the check's detail page already did
  • Dashboard: the dashboard is now translated throughout. Around 500 strings across 55 components and pages still rendered English inside an otherwise French, German or Spanish dashboard: most of the check form (its title and type picker, and the database, mail, game, web, ClickHouse, infrastructure, messaging and network panels, which had no translations at all), the incident page's status-update, notification and "Published on" cards, the whole status-page incident editor, the standalone status-update form, the account device and OAuth consent pages, the status-page and integration forms, the notification detail page, the sidebar and the dashboard tiles. Relative timestamps ("just now", "3d ago") were English everywhere, as were the values in an incident's notification history and the state badge on a published incident, which printed its raw value ("investigating", "monitoring") whatever language you were in. A further 129 strings looked translated but were not: they carried an inline English fallback and no entry in any locale file, so every language got the English — the private locations feature was entirely in that state. Labels that appear both in a picker and on a badge now come from one shared helper, so the two can no longer disagree, and a value from a newer server renders readably instead of leaking a translation key. A new test fails the build if a string is left untranslated that way again (#344)

0.25.1 — 2026-09-07​

(diff)

Bug Fixes​

  • Onboarding: the organization offered to a brand-new account on the "you have no organization yet" screen now takes its address from the person's first name instead of from the sentence in the name field. "Florent's organization" was becoming florents-organizatio — the display sentence run through the 20-character address cap — and in French, German or Spanish the boilerplate led and the name was what got cut, so L'organisation de Florent and L'organisation de Florence both landed on lorganisation-de-flo, the second with a collision suffix on top. A first name in a non-Latin script disappeared from the address altogether. The proposal is now simply florent: it is what the "Will be reachable as …" line shows before you click, and what the Advanced field starts from if you want to change it. The organization's display name is untouched — it is only the address that was wrong. For API clients, POST /api/v1/orgs gains an optional slugBase: unlike slug, which is still taken literally and still answers 409 when it is already claimed, slugBase is a hint the server normalizes and numbers on collision, so accepting the proposed organization can never produce an error the newcomer cannot act on (#341)

0.25.0 — 2026-09-06​

(diff)

Security​

  • Authentication: the viewer role is now genuinely read-only. It was offered in the members list, in invitations and in membership-request approvals as the read-only tier, and an admin who granted it reasonably believed the member could look but not touch — but nothing in the request path told viewer apart from user. Every state-changing endpoint registered under /api/v1/orgs/:org/… was open to a viewer exactly as it was to a full member: creating and deleting checks, acknowledging, snoozing and resolving incidents, commenting on them, editing notification integrations and channels, publishing to the public status page, sending test reports, deleting file attachments, cancelling background jobs, and minting Slack/Discord/Teams install links. Nothing ever answered 403, so there was nothing to trip over. A single floor now sits under every one of those routes and refuses anything below the user role with 403 FORBIDDEN; the dashboard already renders that as "Permission Denied". Two things a viewer legitimately owns are deliberately still writable: their own notification contacts, routes and verification (choosing where they get paged changes nothing for anybody else) and their own API token — which inherits their role, so it can automate reading and nothing more. Incident actions are deliberately not exempt: acknowledging or resolving changes what the whole team is paged about. A team that wants someone to ack incidents gives them user. The rule is read from the member's current role rather than from the token they are holding, so demoting somebody takes effect on their very next request instead of at their next sign-in, and a personal access token minted while they were a user stops writing at the same moment. The MCP server gets the same floor: its tool calls never pass through the REST middleware, so a full-scope MCP token belonging to a viewer could otherwise still have created a check. If you have been relying on viewers being able to write, that stops working with this release — move those members to user.

Features​

  • Live demo: SolidPing now has a public live demo you can hand a prospect: a real organization on the production service, signed into with one click from the login page, with weeks of history across the real multi-region fleet, a status page, SLOs and an escalation policy already firing. Visitors can do the one thing that matters — create a check and watch results arrive — and edit or delete the checks they created, and nothing else; everything they did not create is untouchable, and every other write is refused. Their checks are cleaned up after an hour, and notifications from the demo go to an internal sink rather than to anybody. The catalogue only ever probes SolidPing's own endpoints, never a third party's. It is off by default (SP_DEMO_ENABLED), so a self-hosted install is unaffected until it is switched on (#336)
  • Dashboard: the login page's footer says what it is and leads somewhere. It rendered a bare version number as plain text, so a visitor who wanted to know what SolidPing is, or what changed in the release they are looking at, had to leave and search. It now reads SolidPing v0.25.0, with the name linking to the product site and the version to this changelog (#336)

0.24.0 — 2026-09-06​

(diff)

Features​

  • Onboarding: an account that has just been created, and that no existing organization admitted automatically, is now offered an organization of its own rather than being pointed at the platform's default one. The create form arrives pre-filled with a proposal — "Alice's organization" when the account has a name, a friendly two-word name when it does not — and the URL slug has become optional: leave it alone and the server derives one from the name. Joining an existing organization is still offered, below, as the secondary choice it usually is for someone arriving on their own. Nothing about either is locked: the name is a starting point, and the slug can still be set by hand (#333)
  • Onboarding: on a SaaS deployment, signing in with Google (or any other provider) as a brand-new account no longer files a join request against the operator's own organization. The newcomer used to be told their sign-in had succeeded but that default had not admitted them yet — an organization they never chose and whose admins had never invited them — while the operator collected join requests from every stranger evaluating the product. Self-hosted installs are deliberately unchanged, because there default is usually the one real organization and a colleague's sign-in should ask its admins for access (#333)
  • Documentation: the product tour's video has been re-recorded against the current dashboard, having been cut a month earlier and drifted well behind the UI it claimed to show. It is also simply a better demo: a visible cursor that travels to what it is about to click, text typed a character at a time instead of whole fields appearing at once, and the camera pushing in on whatever is being filled in. It now ships as H.264 alongside AV1, so it plays for visitors whose browsers cannot decode AV1 rather than showing the fallback text (#333)

Bug Fixes​

  • Notifications: the "New membership request" email sent to an organization's admins now links to the page that actually lists pending requests. Its button pointed at the members list carrying a ?tab=requests parameter that nothing has ever read, so an admin who clicked through landed on the member list and had to go find the requests themselves. Links already sitting in inboxes keep working — the old address redirects to the right page. The same email also no longer leaves a blank where the organization's name belongs when that organization has none set, which had been producing sentences reading "has asked to join on SolidPing" and "you're an admin of ." (#333)
  • Status pages: the embeddable status widget now loads when it is served from a custom status-page domain. GET /embed/v1/widget.js returned the status page's HTML shell instead of the script on any custom domain, so the <script> tag a customer had pasted into their own site received HTML, failed to parse, and the widget silently never appeared — with nothing logged anywhere to say so. That was the one host where it mattered most, since the snippet points at whatever hostname the status page is publicly known by (#330)
  • Workers: a worker whose name begins with a digit is now accepted, which fixes docker run ghcr.io/fclairamb/solidping refusing to start roughly three times in four. Docker names a container by the 12-character hex of its id, ten of the sixteen possible first characters are digits, and the worker-name pattern insisted on a leading letter — so the same command worked for one person and failed for the next, presenting as a flaky image rather than as a rule about names. Nothing ever required the leading letter (#334)

0.23.1 — 2026-09-05​

(diff)

Bug Fixes​

  • Slack: connecting a Slack workspace from Integrations → New works again. The install looked like it was going to succeed — Slack showed its usual permission screen, you pressed Allow — and then the browser landed on a generic "install failed" page that said nothing about what had gone wrong. SolidPing was telling Slack which address to send the finished install back to, but then leaving that same address out when it traded the resulting code for an access token, and Slack refuses the exchange outright when the two do not match. Signing in with Slack was never affected, which is why a completely broken install path went unnoticed for so long: the working sign-in sat next to it and hid it. Both steps now read the address from one shared place, so they cannot drift apart again (#327)

0.23.0 — 2026-09-04​

(diff)

Features​

  • Notifications: SolidPing can now tell you when something needs a person, without you watching for it. A support request being filed, or a new organization signing up, is delivered to super-admins over the media you already have configured — email, Slack, Telegram and the rest — and a new Server → Notifications page controls which events go out and to whom. Recipients are resolved at delivery time rather than when the event fires, so an account that has lost super-admin stops receiving them immediately, and a medium you have not configured is recorded as skipped rather than reported as a failed delivery. Signup notices name the organization the new user landed in (#318)
  • Checks: a check's dependencies are now edited on the check edit form, alongside everything else about it, instead of being changed in place on the detail page. The detail page keeps a read-only summary of what a check depends on, so viewing an incident no longer puts editable controls in front of you. Editing also stopped quietly discarding two fields it had been dropping: the dependency kind and its description now survive being loaded into the form and saved back (#318)
  • Onboarding: the “Getting started” checklist has been redesigned. It highlights the next step you have not done yet rather than leaving you to find it, shows completed work without striking it through, and no longer turns its progress bar red on the last step — a full bar now reads as finished rather than as an error. All of its motion respects a reduced-motion preference (#318)

Bug Fixes​

  • Checks: stored credentials can be decrypted again after a restart. Each organization's encryption key is kept wrapped in a small envelope, but the code reading it back expected the bare value, so a freshly started process could not open the key and every credential encrypted with it — check and integration secrets — failed to decrypt until something warmed the cache. This had been the case since May and was invisible to a long-running server, which is why it survived so long. The key is now read back in whichever shape it was stored, is round-tripped once to prove it actually opens before it is cached, and a key that fails to load is reloaded rather than regenerated — regenerating would have produced a working server that could no longer read a single existing secret (#318)
  • database: an embedded-PostgreSQL deployment (SP_DATABASE_TYPE=postgres-embedded) no longer fails under concurrency with sorry, too many clients already. The embedded server is started with ten connections, three of which PostgreSQL reserves for superusers, while the pool opened against it was unbounded — so past seven simultaneous queries the extras were refused outright instead of waiting their turn. The pool is now bounded below that ceiling, which turns refusals into a short queue. This mode is intended for tests and light local use and its limits are fixed, as the database configuration page now states (#318)

0.22.1 — 2026-09-03​

(diff)

Bug Fixes​

  • Heartbeat checks: a heartbeat that stops beating is now detected on schedule instead of after a random delay. Every period the scheduler writes its own result row, and it was then reading back the newest row of any kind to decide the check's state - which, from the second period after a beat, was its own previous row rather than the beat. Detection therefore became a coin flip on scheduling jitter: a dead heartbeat stayed up for an unpredictable number of extra periods, with no upper bound. When it finally flipped, the row that opened the incident reported an overdue time of a few milliseconds and a last-signal timestamp pointing at another scheduler row, and those wrong values were what the incident snapshot and every notification carried. The "run started but never completed" timeout could not fire at all, because each evaluation re-anchored the run on itself. The evaluation now reads the newest inbound signal specifically, so overdue detection fires when it should, the diagnostics name the real beat, and stale runs time out after two periods as designed. The same fix covers email checks, which share the code path (#309)
  • Heartbeat checks: a scheduler row no longer looks like a beat that was never recorded. Both kinds of row read "Heartbeat received" with status up, so opening the row written seconds after a ping showed no caller details and a bare JSON dump - and the reasonable conclusion was that the ping had not been recorded at all. Scheduler rows now say what they are: the on-time message reads "Heartbeat on time" while a real beat keeps "Heartbeat received", the results table tags them with a muted "Evaluation" badge, and opening one explains which worker wrote it, when the last real signal arrived, how long before this evaluation that was, and links straight to that beat. A row whose most recent signal was a failed beat now says the last heartbeat reported failure, rather than the flatly untrue "no heartbeat received". What is stored for an actual beat is unchanged, byte for byte (#309)
  • Status pages: a published incident can no longer outlive the outage it describes. Publishing an already-resolved incident created a fresh entry in the investigating state, as though the outage were live, and publishing also marked the entry as human-authored - which meant the default "resolve automatically if untouched" policy behaved exactly like "never". One such entry sat open on a public page for ten days: the wallboard showed "Some Systems Degraded" while every check was up and the ordinary status page said "All Systems Operational", with nothing in the dashboard connecting the two. Publishing a resolved incident now produces a resolved entry carrying the incident's own resolution time and a retroactive timeline, linking an incident to a page no longer counts as taking over the narrative, and auto-resolve now covers any entry linked to the resolving incident whether a person or a machine published it. Free-form entries with no linked incident are still never touched automatically. Relapse handling was widened to match, so an entry that closes automatically also reopens automatically rather than leaving a live outage unannounced (#309)
  • Status pages: where this state can still arise, it is now visible. The dashboard warns on both the checks list and the status-page view when an incident is open on a public page while every check behind it is up, linking straight to the entry, and the wallboard says when its amber comes from a published incident rather than from a failing check - without claiming all services are passing on a page where they demonstrably are not (#309)
  • Heartbeat checks: the embedded TCP/UDP panel on a heartbeat check is collapsed by default. It was fully expanded on every heartbeat check, doubling the height of the endpoint card and pushing the response-time chart down the page for the majority of users who only ever use the HTTPS URL. It now collapses behind a summary that still names what is enabled, with each netcat one-liner as its own disclosure. The token-rotation warning deliberately stays outside the collapsed section, because it is a security notice. The Arduino/ESP sketch that was rendered inline has been replaced by a link to the documentation page that already published it alongside the counter recipe and the security discussion, so the two copies can no longer drift apart (#309)
  • Reports: the "create a weekly uptime report for me" shortcut produces a report with checks in it. It previously created a schedule with an empty scope - which the server correctly treats as covering the whole organization, but which presents as two empty pickers and nothing the reader can point at. It now attaches the ten most recently created checks, falling back to the organization-wide scope only when there are no checks to attach (#309)

0.22.0 — 2026-09-02​

(diff)

Features​

  • Heartbeat checks: embedded devices can send beats without a TLS stack. Heartbeat checks accepted beats over HTTPS only, which ruled out the hardware the primitive suits best - microcontrollers, cellular modems driven by AT commands, battery sensors, legacy PLCs - because TLS costs tens of KB of RAM, a certificate store and a correct clock, and HTTP framing is pure overhead for a message meaning "I'm alive". Two optional listeners, off by default and sharing port 4001 on both TCP and UDP, now accept a one-line beat that is still debuggable with netcat: SP1 carries the token in plaintext, and SP2 is signed with HMAC-SHA256 keyed by the token, so no secret travels on the wire and a beat stays around 80 bytes. Replay protection is a strictly-increasing per-check counter enforced by the database itself, so an old datagram is refused and even the newest one cannot be replayed. The per-check require_hmac option rejects SP1 outright, and the dashboard pairs that toggle with a prompt to rotate the token, because a check that ever accepted SP1 has already exposed the key that signs SP2. Failed beats get no reply at all, so the listeners cannot be used to probe which organizations, checks or tokens exist, and a reply is never larger than the datagram that prompted it. Enabling the ports is a deployment decision; the new "Embedded / push monitoring" documentation page covers both message forms, the device-side counter recipe and the security trade-offs of each (#301)
  • Heartbeat checks: a beat can carry readings, not just aliveness. Both message forms accept an optional annotation - a status word plus key=value pairs - and numeric values are stored as check metrics, so battery voltage, signal strength or temperature arrive in a single UDP datagram and become charts on the check page. A malformed annotation never invalidates the beat: parsing is best-effort and falls back to storing the raw text, because a firmware typo in a key name must not make a healthy device look dead. The HTTPS ingest accepts the same annotation, so the field means one thing across all three transports (#301)
  • gRPC checks: gRPC checks report where the time actually went. connection_time_ms was measuring almost nothing - the client connects lazily, so that number covered little more than object construction while the real DNS, TCP and TLS cost hid inside the RPC timing. The connection is now established up front and instrumented, so a check reports genuine DNS, connect, TLS-handshake and RPC durations, and a failure is attributed to the phase that failed instead of collapsing into one opaque RPC error. Checking a service the server never registered - the most common misconfiguration - now says so in plain words rather than surfacing a raw NotFound. A NOT_SERVING response still keeps its measured latency, so a service can be watched slowing down before it drains (#301)
  • gRPC checks: gRPC checks can authenticate. Checks now send request metadata, including secret metadata that is encrypted at rest and never echoed back, so a health endpoint behind an authenticating proxy can be monitored at all. The dashboard form was previously a stub exposing only host, port, service name and TLS; it now also offers TLS verification skipping, the timeout, and editors for both plain and secret metadata (#301)
  • Reports: uptime report emails compare periods, rank by severity, and summarise response times. The scheduled report showed a single period in isolation. It now carries period-over-period trends, a response-time summary with minimum, maximum and slow-sample counts, the average incident duration, and a check table ordered worst-first with a per-day availability strip - so a failing check can no longer sit unnoticed at the bottom of an alphabetical list, and the table's 50-row cap now keeps the worst rows and says how many were left out. The degenerate cases got as much care as the happy path, because that is where a recurring digest starts misleading people: zero is never coloured as an improvement, a period with no baseline omits the comparison instead of printing a meaningless "+/-0.00%", a monitor that was down for the whole period gets a plain factual sentence rather than a grid of zeros and a falsely cheerful response-time trend measured on error responses, and days before a check existed are grey rather than red (#301)
  • Dashboard: the "Getting started" checklist rows have their own background and a distinct completed state, instead of the card's tint showing through every row and leaving finished steps looking much like outstanding ones (#301)

Bug Fixes​

  • Checks: editing a heartbeat check no longer destroys its ping token. Configuration updates replace the stored object wholesale on the public API, and the regenerated token was being discarded because validation ran against a copy - so a PATCH that set any single heartbeat option silently invalidated the URL the device was posting to, and the check went quiet for reasons nothing explained. The token is now preserved across a partial update, while an explicitly supplied one still wins (#301)
  • Reports: an uptime report with no incidents in either period prints "0" rather than "". The previous period's incident count was omitted from the rendered data whenever it was zero, which is the commonest healthy report there is, and the template had nothing to substitute (#301)

0.21.2 — 2026-09-01​

(diff)

Bug Fixes​

  • storage: uploaded files survive a container restart. SP_FILESTORAGE_LOCAL_ROOT was never actually read — the configuration loader mapped the name to a key that did not match the one the setting is declared under, so an operator who pointed it at a mounted volume kept writing to the default path inside the image and lost every organization logo, status-page asset and incident screenshot on the next deploy. Nothing failed at upload time, which is why it went unnoticed until a later read returned file not found in storage. The variable is now honoured, and the Docker, Docker Compose and Kubernetes installation guides mount a volume for it — none of them did, while all of them mounted one for the database. File storage also has a documentation page of its own for the first time, covering both backends, how credentials are resolved, and worked examples for MinIO, OVHcloud and AWS (#296)
  • Incidents: a cascading outage pages once for the cause instead of once per dependent. Rollup could only suppress the pages it had not yet sent, and a parent check is inherently later to notice an outage than the services depending on it — by up to one probe interval plus its connection timeout, even when parent and children are configured identically. The children therefore finished their confirmation window first and paged before there was a parent incident to attribute them to; a real broker outage produced five reports instead of one this way. A check whose confirmation has elapsed now waits while a hard parent is itself still validating, so the parent opens first and the existing rollup suppresses the children before anything is sent. The hold costs nothing when the parent is healthy — a check failing on its own still confirms at exactly its configured period — and each parent can only hold a child for one bounded window, so a parent stuck validating cannot delay a page indefinitely. The dependencies view now also warns, without blocking anything, when a check's confirmation is too short for its parent to have plausibly noticed the same outage (#296)
  • Incidents: resolving a rollup parent no longer erases the record of the cascade. Detaching a child that had already recovered cleared both its suppression flag and the attribution naming the incident that caused it, so once the parent resolved, ten of eleven dependent incidents from a real outage read as though rollup had never run, and the investigation had to be rebuilt from the events feed. The attribution is now kept as the historical record and only the suppression is cleared, and the detach finally emits the incident.rollup_detached timeline event the documentation had described for some time but which had never existed. A detached child that fails again before its own incident closes still pages, exactly as before (#296)
  • Incidents: the confirmation hold measures the same window no matter which path reported the result. The per-check timeout it depends on was converted from configuration separately at each call site, and the heartbeat, inbound-mail and MCP ingest paths never did the conversion, so they silently used the built-in 15 second default. At shipped settings the two values agree and nothing is visible; an operator who raised or lowered the ceiling got a hold whose length depended on which path happened to see the failure. There is now a single conversion every consumer goes through (#296)
  • Dashboard: the “create your first check” screen goes away as soon as a check exists. Whether the dashboard shows onboarding or its normal view is decided by a statistics endpoint, and three caches stacked up in front of it: a one-minute server-side snapshot with no invalidation, no client-side refresh when a check is created or deleted, and a poll stretched to five minutes whenever the live connection is up. Together they could leave the onboarding screen sitting over an organization that already had checks for several minutes, and reloading the page did not help, because the server-side snapshot outlived the reload. Creating or deleting a check now clears that snapshot immediately and refreshes the dashboard, including when the check is created over the API or through MCP rather than in the browser (#296)

0.21.1 — 2026-08-31​

(diff)

Bug Fixes​

  • Documentation: the documentation sidebar names the API reference "API Reference" again, instead of listing it as the raw folder name api. The sidebar is generated from the folder layout, so a section's display name has to come from a _category_.json file inside it — that file was already accounted for in the repository's ignore rules, with a comment saying the label is kept, but it had never actually been committed, so the site fell back to the directory name (#291)
  • Documentation: /docs/changelog lists the real release history again instead of reading "No changelog available yet.". The page is generated at build time from this file, but the container build never copied CHANGELOG.md into the stage that builds the documentation, so the generator hit its fallback and every published image shipped an empty changelog page. Local documentation builds were unaffected — the file is simply there on a developer's machine — which is why it went unnoticed (#291)

0.21.0 — 2026-08-31​

(diff)

Features​

  • Status pages: TV mode — a wallboard rendering of any status page, at /{org}/{page}/tv, /{org}/tv for the default page, and /tv on a custom domain. One non-scrolling viewport built to be read from across a room rather than at arm's length: the ambient state is carried by an icon and the state spelled out in words as well as colour, active incidents are cycled rather than shrunk to fit, and the board shows how long it has been since the last incident. If it stops hearing from the API it drops to grey instead of leaving a frozen green screen up during an outage. Pages can now also publish a page-level uptime figure, and a revocable kiosk token lets a wall panel render a non-public page unattended for months without a login (#288)
  • Status pages: a section can now carry a membership rule instead of being hand-curated — every check, or every check matching a set of labels. A reconciler materializes the matching components and keeps them in step as checks come and go, so a page no longer drifts out of date every time someone adds a check. Sections dedupe page-wide, and a section whose matches were all claimed by an earlier one now says so, with the remedy, instead of rendering empty and looking like a broken label filter (#288)
  • Status pages: the badge and widget previews on the appearance page open the status page when clicked, and the copyable badge snippets are now links themselves — a badge pasted into a README or a footer points back at the status page instead of being an inert image (#288)
  • Status pages: affected services on the wallboard name the failing checks and how long each has been failing, and the recently-resolved strip says when an incident happened rather than only how long it lasted. Previously a board could go fully red while its only text read "N days since the last incident", because the colour comes from live check data but the explanation waited on incident publication (#288)
  • Onboarding: one-click defaults. A magic wand on the integrations, report-schedule, status-page and status-pages-list screens creates the sensible default outright — the status-pages wand builds a page named after the organization with every check attached — and only appears while the matching Getting Started step is still outstanding. The checklist card is tinted, its rows are clickable, and its status-page step now lands on the list where the wand is, rather than on a blank form (#288)
  • Slack: the /solidping slash command works. It was registered in the app manifests and documented publicly but had no handler, so every documented subcommand — /solidping help included — answered Unknown command. check, comment, list, create, config, incidents and help all now route through the same parser as an @-mention and answer privately in-channel, and check acknowledges immediately and reports back over Slack's response URL rather than risking the three-second timeout (#288)
  • Email: notification emails ship a designed dark palette, and the dashboard's email preview gained a light/dark toggle so you can see both before sending (#288)
  • WhatsApp: support messages are marked read when an operator reads or replies to them, so the person who wrote in sees their message was picked up (#288)
  • Dashboard: organizations can be switched from the command palette, and its ambiguous "Settings" entry is now "Organization Settings". Creating a check from the quick-start lands on that check's own page rather than a generic dashboard (#288)
  • Documentation: the changelog is published at /docs/changelog, generated from this file at build time with reference clutter stripped and dependency bumps filtered out (#288)

Bug Fixes​

  • API: cross-origin requests are governed by a real allowlist. Every response paired Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true — a combination the Fetch standard forbids, so the credentials flag never took effect and any cross-origin caller sending cookies or an Authorization header was refused by the browser. Allowed origins are now configurable (SP_CORS_ALLOWED_ORIGINS, defaulting to the instance's own public URL) and echoed back individually with Vary: Origin; genuinely public, credential-free surfaces — public status pages, the embeddable widget and the analytics ingest path — keep a wildcard without credentials, since the sites embedding them can never be listed in advance. Nothing was broken by this in practice, because the dashboard is same-origin, but it blocked any second origin from using the API (#288)
  • API: the analytics ingest proxy no longer emits duplicate CORS headers, and answers preflight. It forwarded the upstream's Access-Control-* headers on top of its own, so browsers saw each header twice and rejected the response outright — invisible to curl and every other non-browser probe — and it had no OPTIONS route at all, so any preflighted request failed before it was sent (#288)
  • Checks: a check whose type is changed now reaches the workers. The comparison deciding whether a check's configuration had changed was blind to the type field, so a type-only edit was saved but never propagated, and the worker went on running the old configuration indefinitely (#288)
  • Checks: PATCH /checks validates the merged configuration. Partial updates skipped the type-specific validation the create path runs, so an update could store a configuration that could never have been created (#288)
  • API: an explicit 0, false or empty string on create now reaches the database. The ORM's default-value annotations caused zero values to be omitted from the insert entirely, so a field explicitly set to its zero value silently took the schema default instead — repository-wide, on every affected column (#288)
  • Billing & entitlements: a plan change takes effect immediately. The cached per-organization rate-limit bucket kept its old capacity after entitlements changed, so an upgrade did not raise the limit until the bucket expired on its own (#288)
  • Authentication: confirming a registration no longer logs you straight back out. For a new email matching no existing organization the confirmation carried no session, so the dashboard stored the string "undefined" as a token, failed its next call and bounced to the login screen reporting an expired session — while the account had in fact been created, so trying again reported the address as taken. The dashboard also no longer 401-bounces off its own /register page (#288)
  • Dashboard: an invitation link that fails for a transient reason no longer claims to be expired. Any error at all — a rate limit, a server error, a dropped connection — rendered the same "this invitation link is invalid or has expired" card, which is a dead end for someone holding a perfectly good invitation. Only a genuinely unknown token shows it now; everything else offers a retry (#288)
  • Notifications: acknowledgement, comment, resolution and reopen replies in Slack link to the incident first rather than to the check, since the incident is where the context lives (#288)
  • Dashboard: comments on an incident render their formatting instead of appearing as plain text, the organization section opens on Members to match its own tab order, and the check detail page's documentation icon sits at the top right like every other page (#288)

0.20.0 — 2026-08-29​

(diff)

Features​

  • Onboarding: a getting-started checklist on the org dashboard, appearing once the first check exists. Every step is derived from real resources rather than a stored per-step flag, so it cannot drift out of sync with what the org has actually configured, and it self-dismisses with an "all set" state instead of squatting on a fully configured dashboard. It replaces the one-shot first-result banner, whose dismissal lived in localStorage and so was lost on another device: dismissal now persists server-side per user per org, and can be re-enabled from the account page (#282)
  • Alerting: a newly created org is no longer silent by default. It is seeded with an enabled default email integration addressed to its owner — which the existing default-integration auto-attach then wires onto every check the org creates — and an org-wide weekly uptime report. Seeding is best-effort and never fails signup; existing orgs are untouched, and the bootstrap and test orgs are deliberately excluded (#282)
  • Status pages: publishing a check now takes one step instead of three. The check detail page offers "Publish on a status page", which opens the create form prefilled with that check; creation accepts an initial set of checks and always lays down a default "Services" section, so a new page is no longer born empty. Page, section and resources are written in a single transaction, so a rejected request leaves nothing behind (#282)
  • Checks: JSONPath assertions are editable in the dashboard. The editor existed but had never been wired into the HTTP form, so assertions could only be managed through the API — and, worse, saving the check from the form silently discarded them. Failed assertions now also surface on the check detail page (#282)
  • Incidents: unacknowledgement is announced to the same reach as the original alert, and escalation resumes from the rung it was paused at rather than restarting or silently stopping (#282)
  • SFTP checks: keyboard-interactive authentication, so servers that decline plain password auth can still be checked (#282)
  • Reports: uptime report emails link straight to the check and SLO they describe (#282)
  • API: per-user UI state endpoints (/api/v1/me/ui-state/:key), scoped to the authenticated user with an allowlisted key shape and a size cap, so a per-user preference no longer has to live in browser storage (#282)

Bug Fixes​

  • Authentication: registering or accepting an invite with a too-short password answers 400 VALIDATION_ERROR instead of 500 INTERNAL_ERROR. A user typo was being reported as a server fault, paging Sentry each time. A sweep of the sibling error helpers found three further unmapped sentinels falling through to the same generic 500 (#282)
  • Checks: /checks/validate no longer answers valid: true for a payload that POST /checks then rejects. The two endpoints decoded into unrelated structs, so validate never even saw fields the create path refuses — including internal, the one field guarding the quota exemption. Request-level validation is now a single shared routine both paths run, with create's status codes, field names and messages unchanged (#282)
  • Dashboard: uploading an organization logo no longer blocks the settings form. The upload returns a relative storage path, which landed in a type="url" input and tripped native validation — so after uploading a logo the owner could not save a name or slug change at all. Uploaded file and external URL are now distinct, explicitly labelled sources (#282)
  • Dashboard: an in-flight organization name or slug edit is no longer reverted by the profile refresh that follows a save, which had been silently discarding whatever the user typed in the interval (#282)
  • Dashboard: the command palette finds an entity created moments ago. Its entity search shared a cache key with other screens under the app's default staleness window, so a status page, SLO or escalation policy created in the last minute could stay invisible to search even though it already existed (#282)
  • Dashboard: the maintenance-window edit form waits for its check associations before mounting. A window's fields and its checks load as two independent queries, and the form seeded its selection once at mount — so if the checks were still in flight the form opened empty and saving dropped the window's attached checks (#282)
  • Dashboard: the checks, incidents, members, SLOs and integrations tables are usable on a phone, dropping columns by breakpoint and scrolling inside their own container instead of giving the page a horizontal scrollbar (#282)
  • Dashboard: the incidents list shows the check's name rather than its slug, and the dashboard's issues banner links through to what it is reporting (#282)
  • Dashboard: the response-time chart marks a failing sample distinctly instead of rendering it as an ordinary point, and the check multi-picker shows names instead of raw UUID chips (#282)
  • Agents: a re-enrolling system agent supersedes the row it replaces, so a redeployed worker no longer leaves a stale duplicate behind (#282)

0.19.1 — 2026-08-28​

(diff)

Bug Fixes​

  • config: bind SP_ENCRYPTION_MASTER_KEY so credentials stop being stored in plaintext (#276)
  • Email: link the check page in incident emails instead of the down host (#274)
  • name the license file LICENSE so pkg.go.dev detects it (#272)

0.19.0 — 2026-08-27​

(diff)

Features​

  • Scheduling: a check scheduling page that brings an org back under its per-minute rate cap. It shows where execution demand actually comes from, lets each check be stretched or disabled inline with the header total recalculating before anything is saved, and offers an auto-rebalance that proposes longer periods until the org fits its plan. Passive check types are excluded from the table and the omission is explained rather than left as a silent gap. The table is fully usable on a phone, scrolling inside its own container instead of the page. Reached from the checks list and from the over-limit banner (#269)
  • Rate limits: an over-limit org is now told that its check executions are being skipped, rather than left to discover unexplained gaps in its own data. A daily rate-limited-skip counter records the loss, effective_scheduled_at is preserved across a rate-limited deferral so a skipped run is distinguishable from a late one, and internal checks are exempt from both per-org rate gates. checksPerMinute demand, limit and daily skips are exposed on the entitlements payload and surfaced on the Usage page (#269)
  • Availability: a real 24h availability figure, computed server-side, replacing a fabricated 100%. A bucketed check-availability endpoint sits behind the shared engine with region-scoped reads and one shared bucket classifier, so the dashboard, the uptime bar and the status page cannot drift apart on what "available" means. dash0 renders the true KPI and an availability strip aligned to the response-time chart; status0 colors its chart strip by availability rather than by incidents alone (#269)
  • Billing & entitlements: a superadmin org-limits editor, with explicit unlimited toggles rather than an empty field standing in for "no cap", plus the endpoints behind it. An admin override now outranks the next billing push, so a deliberate manual change is not silently reverted by the billing service on its next write (#269)
  • Integrations: Gotify and Zulip notification senders, wired into the integration registry with their dash0 forms, icons and locale entries (#269)
  • validation: check validation now reports every finding rather than stopping at the first, each carrying a severity, a machine-readable code and the offending slug, alongside an org-rate projection and rate-cap warnings. The document-level internal flag is flagged in validate and its contract documented (#269)
  • support: the reply pre-flight is per-thread and refuses unroutable replies outright instead of accepting a message that could never be delivered, surfacing the per-thread reason and offering a resend (#269)
  • API: the spec covers the above — CheckStats.availability24h, the superadmin entitlements editor and suppression fields, and validate's severities, codes and extended request. internal is no longer advertised as a writable check field (#269)

Bug Fixes​

  • Slack: make the install scope request match the app manifests (#267)
  • Billing & entitlements: an admin row's null cap means unlimited, and org admins can no longer outrank billing. Legacy admin entitlement rows are relabelled to org-admin before they resolve, so a historical row cannot silently read as unlimited, and an org-admin row is surfaced as its own provenance rather than as free defaults (#269)
  • Checks: internal is refused on every check write path and no longer typed as writable in dash0, closing a route by which a client could mark its own check internal and escape the org rate gates (#269)
  • Dashboard: a 403 renders Permission Denied instead of a generic load error, so a permissions problem stops reading as an outage (#269)
  • Dashboard: the scheduling meter is anchored on the server's own demand figure, its period select floors at the server's 10s minimum, and an unparseable stored period falls back to the ladder rather than rendering blank. A check's real custom period is shown, with a warning before a change would break the org's rate cap (#269)
  • Dashboard: the design reference no longer overflows the page on mobile. Every example row put its preview in a bare grid item, which defaults to min-width: auto, so a preview wider than its column widened the track instead of wrapping — giving the whole page a horizontal scrollbar at 375px (#269)
  • Dashboard: the checks breadcrumb names the scheduling page. It matched the checks section but had no leaf, so it rendered a bare non-clickable "Checks" crumb indistinguishable from the list page, with no way back (#269)
  • Integrations: stale provider links on the Slack and Discord installs are healed rather than left pointing at a workspace that no longer resolves (#269)
  • Documentation: the API reference resolves its base URL from the browsing host, and a deliberate pick is kept across reference pages instead of being reset on navigation (#269)
  • Zulip: the incident-ref suffix is budgeted before a topic is truncated, so the reference survives the character limit instead of being cut off it (#269)
  • CLI: the entitlements source enum is converted before concatenation rather than formatted as its underlying value (#269)

Miscellaneous Chores​

  • release 0.19.0 (#270)

0.18.3 — 2026-08-26​

(diff)

Bug Fixes​

  • Dashboard: the region picker shows which regions can actually run a browser check. Workers have advertised a browser capability for a while and the regions API aggregates it per region, with the same three-state semantics as IPv6 — but the dashboard rendered none of it, so picking regions for a browser check was guesswork. It now renders as a single icon, because a second text badge beside the IPv6 one would crowd the picker: the state lives in the icon's color and the tooltip. "Unknown" stays distinct from "no" — a region with no live worker, or one running an older agent, may well support browser checks, and saying "no" would be wrong. It is a hint, never a gate: nothing is hidden, filtered or disabled, and the worker remains the authority at run time. Shown in the check form's region picker and on the private locations page (#265)
  • API: /openapi.yaml is served with the requesting host as its server rather than a hardcoded https://solidping.io. The interactive explorer already rewrote the list in the browser, but the raw spec is what code generators, Postman and agents read, and they have no such hook — so a spec fetched from a self-hosted instance, a custom domain or localhost pointed every generated client at the wrong host. The origin is now derived per request, honouring X-Forwarded-Proto behind a proxy; the cloud stays listed as a second entry and is never duplicated when the origin already is the cloud; and a Vary keeps a shared cache from handing an http:// spec to an https:// client (#265)
  • http: embedded files are served with a real Content-Type. The shared helper passed a whole path to mime.TypeByExtension, which takes an extension, so it resolved to nothing for every file — and wrote that empty string through as the header, which is worse than omitting it, because an explicitly-set Content-Type suppresses Go's own content sniffing. /openapi was serving its HTML genuinely untyped as a result. The YAML type is pinned explicitly rather than read from the host's mime database, which answers differently on Linux and macOS and would otherwise make the header depend on the build machine (#265)
  • Documentation: the migration guide's links to the API reference resolve again, pointing at the generated reference's own index page instead of a category page that no longer exists (#265)

0.18.2 — 2026-08-25​

(diff)

Bug Fixes​

  • Email: transactional mail is branded, and no longer mangled by real mail clients. Every message now wears the organization's logo (the status page's for subscriber mail, none at all under white-label) and carries a preheader, so the inbox preview line stops scraping the wordmark or a raw URL. Subjects and plaintext parts render through text/template: a check named "Search & Discovery" reached the inbox as "Search & Discovery" — in the subject line — while the HTML part keeps escaping as it must. The palette is declared light-only, because Apple Mail, Outlook.com and Gmail on Android auto-invert an undeclared one, recoloring the status banner that carries an incident alert's entire meaning. The uptime report built its fact grid from unstyled <th> cells, which rendered centered, unpadded and ragged; it now uses the styled label/value grid the rest of the mail shares. Alerts lead with the figure they are about — total downtime, burn rate, budget remaining — timestamps carry their zone, durations read "15m" rather than "15m0s", the incident UUID moved from a table row to a support footnote, and action buttons stack full-width on a phone. Gradients, elevation and state color throughout, each paired with the flat fallback Outlook needs. A dev-only catalog at /api/mgmt/email-preview (Test → Emails in the dashboard) renders every template through the same formatter the mailer uses (#258)

0.18.1 — 2026-08-25​

(diff)

Bug Fixes​

  • Dashboard: the response-time chart no longer says "No data available" while it is still loading — on first paint and on every day → week → month switch. useChartWindowResults gained an isEmptyPending signal, so the terminal empty state is gated on the whole two-pass window having settled rather than on pass 1 alone, while the progressive render keeps drawing rollups in the very same DOM node as raw merges in. The chart-window suite is also frozen against the wall clock instead of racing it: a fixture sampling Date.now() once per region could straddle a millisecond boundary and reorder the merged series, turning one assertion unsatisfiable and its waitFor into a guaranteed timeout (#255)

0.18.0 — 2026-08-25​

(diff)

Features​

  • Discord: a first-class Discord bot, not just a webhook. Install it into a server, map servers to organizations, and get incidents as bot-owned threads that edit in place and carry acknowledge and comment buttons. Org members map to Discord identities so on-call mentions reach the right person, comments posted in the thread are ingested back onto the incident through a Gateway supervisor, slash-command and mention dispatch is transport-agnostic, and every inbound interaction is Ed25519-verified with stale and unsigned requests rejected. Ships with a bot settings panel and a server-admin page in the dashboard (#246)
  • audit: an organization audit log. Every session-minting path, membership change, token operation, config apply, and escalation / on-call / maintenance / integration / status-page mutation emits a typed event carrying actor metadata and a normalized source address. Payload redaction fails closed on anything it does not recognize, failed logins are flood-controlled, and retention is swept by a cleanup job. The events API gained family, type, actor, target and admin-only IP filters plus working cursors; the dashboard gained an admin-gated audit page; and every event family has a human identity in all four locales (#246)
  • SLOs: burn-rate alerting on SLOs. Define an alert policy over an SLO and get paged when the error budget burns too fast, evaluated over multiple windows against a shared evaluator. Burn incidents are a distinct incident kind rather than a check failure, with their own lifecycle entry points, their own email and Slack templates, and a periodic sweep job. The dashboard gained an SLO alerting section, a burning badge and a policy edit route (#246)
  • diagnostics: MTR-style path tracing on network failures. A pure-Go path prober with a privilege ladder traces the route when a network-reachability failure opens an incident, so the incident shows where the packets actually stopped. Exposed as an org-level default plus a per-check policy, rendered on the incident and on the onset result page, and pinned never-public (#246)
  • support: a support inbox. Inbound human messages are captured from email, WhatsApp, Telegram, SMS, Slack DMs and Discord DMs into threads and messages, with idempotent capture, abuse ceilings and a mailbox mirror. Replies go back out through per-provider adapters and take their delivery status from the existing provider callbacks, and instance support Reply-To is classified fail-closed per template. Ships with a retention job, org-deletion detach, an OpenAPI-documented API and a /support inbox in the dashboard (#246)
  • Email: the JMAP inbox consumer is safe to run more than once — inbound mail is deduplicated by Message-ID before insert, claimed by archiving it before processing, and the consumer runs under a Postgres advisory lock (#246)
  • Status pages: branding and private pages. Upload a per-page logo and favicon (gated by a white-label entitlement), password-protect a page behind an unlock cookie, and let visitors subscribe over webhook or Slack. Branding moved into settings, and public assets are authorized by file topic (#246)
  • Incidents: screenshots of what the browser saw. Browser checks can opt into capturing a screenshot on failure, persisted when the incident opens and rendered in the dashboard. Built on a new generic attachment rail (files.topic / files.details) with an agent upload endpoint, an out-of-band upload frame, a bounded TTL'd capture LRU for deported agents, orphan reaping, and attachments pinned out of public payloads (#246)
  • Incidents: acknowledgement now has a face. The acknowledging actor is resolved and exposed, every paged channel is notified that the incident was acknowledged (Telegram included), and the dashboard and the organization events feed both name who acknowledged it (#246)
  • Incidents: flapping is visible. flap_level is recorded and exposed on create and reopen, checks carry a live flapState, and the dashboard shows a flapping state on check detail plus a "flapping ×N" badge on the incidents list and detail, alongside the actual reopen-cooldown window (#246)
  • Incidents: check groups are handled as groups. Active group incidents are closed and member-row incident binding stopped, status pages consolidate group members into a single public entry, the dashboard groups active incidents by check group at read time, and hard children are re-evaluated when a parent incident opens (#246)
  • custom-domain: an explicit custom-domain lifecycle — a stored-cert handshake, a legible 503 while the domain is not ready, a grace and re-promotion sweep, a demotion alert (including demotions reached through Verify), and DNS diagnostics surfaced in the dashboard (#246)
  • regions: a server-scope region migration API with stale-region reconciliation at startup, plus ghost-region detection behind GET /system/regions/health (#246)
  • watchdog: the platform monitors itself. A detector package feeds per-detector anomaly gauges through Prometheus, driven by an hourly platform-watchdog job with delivery and parameter validation (#246)
  • Authentication: flagged accounts are forced through a password rotation, with a dedicated rotation screen in the dashboard (#246)
  • Authentication: stale SSO provider links heal themselves. A soft-deleted organization used to leave its Discord guild or Slack team link alive, which permanently bricked login for everyone in that guild — the stale link is now cleared and re-linked on the next sign-in, and the same fallback covers user links across all nine connectors (#246)
  • import: UptimeRobot as an import source, with a golden-tested converter and a dashboard source picker (#246)
  • performance: result and chart queries seek instead of scan. Result blobs are skipped when unrequested, the keyset cursor seeks by row value, the chart fetch is split at the raw/rollup boundary into parallel tier queries (and still seeks on SQLite), a per-check per-tier recent-results filter cannot ask for a mixed tier, status-page response times are fetched per check and per tier instead of scanning results, raw-tier queries are clamped to the retention band and report the effective window, and status-page cache directives derive from page visibility (#246)
  • Dashboard: faceted status and type filters on the checks list, a check filter on the incidents list, documentation links across SLOs, badges, discovery, events and dependencies, incident timestamps rendered in local time with UTC on hover, invitation email delivery status led by the address with the link as fallback, and every auth method a session can carry is labelled (#246)

Bug Fixes​

  • audit: every session-minting path is recorded, not just password logins; redaction fails closed on unrecognized payload value types and keeps pointer-valued fields; the events cursor gained a uid tie-break; the OAuth grant path is audited and the guard widened past the auth package; and an OAuth grant presented by the wrong client is recorded as token misuse (#246)
  • traceroute: an HTTP response stall is no longer labelled a connect timeout, the connection phase resets at dial start so a redirect chain keeps a real connect timeout, a panic on either trace goroutine is recovered, the per-check policy survives export, import and clone, and an agent-uploaded capture is stamped with the probing region (#246)
  • support: SP_SUPPORT_RETENTION_DAYS=0 genuinely means keep forever, an abuse-ceiling drop counts as throttled rather than failed, and the re-scan path fails closed with the dedup lookup scoped to the org index (#246)
  • charts: the tier merge is keyed on org and check so navigation cannot serve stale rows, the seam is anchored on the bucket edge with the chart window resolved once, and same-period_start rollup points get a uid DESC tie-break (#246)
  • Status pages: public incident history is cached by visibility, and public pages no longer vary on Cookie (#246)
  • Notifications: browser push notifications are incident-aware, escalation Slack DMs address the check by name and number with an incident link, and the agent websocket subscribes to check.created before announcing the connection (#246)
  • Checks: the 100th auto-slugged check for one host no longer 500s, and validating is accepted in the status filter (#246)
  • db: generic attachments moved out of the already-released migration 014 into a new 015 (#246)
  • config: a hostname-derived worker slug is slugified instead of refused, and the fixed-point property holds by construction (#246)
  • Dashboard: the login page no longer unmounts mid-org-picker redirect, apiFetch tolerates an empty body under any status rather than only 204, breadcrumb leaves resolve for every Organization tab and deep route, the SSL check type is labelled TLS across all surfaces, and an empty check duration renders a literal em dash (#246)
  • Documentation: the API reference index is served at /docs/api (#246)
  • Slack: register the login callback in the prod manifest (#251)

0.17.0 — 2026-08-20​

(diff)

⚠ BREAKING CHANGES​

  • Notifications: Opsgenie is removed and replaced by PagerDuty. Migration 015 hard-deletes existing Opsgenie integrations on upgrade — recreate them as PagerDuty integrations using an Events API v2 routing key (#240)
  • API: the results list endpoint no longer returns pagination.total, nextCursor or hasMore. They were never populated with real values; clients paginate with limit plus the returned page size (#240)

Features​

  • SLOs: service level objectives, end to end. Define an SLO over a check or a group with a rolling or calendar window, and get its status, its error budget, and how that budget burns down over time. The budget math is DST-safe across calendar windows, maintenance is tagged at ingest so planned downtime never eats the budget, and the burn-down series accrues consumption per step so it can only ever fall. Ships with SLO list, detail and edit pages in the dashboard, an SLO coverage chip on checks, and a maxSlos entitlement (#240)
  • SLOs: scheduled uptime reports — attach a recurring email digest to an SLO. Schedule CRUD, a test-send button, the delivery job, and an email template carrying List-Unsubscribe headers and a per-recipient unsubscribe link (#240)
  • Status pages: incident publications. An incident on a status page is now a first-class, editable overlay on top of the underlying check incident: publish or unpublish by hand, or let the page auto-publish after a debounce, with a per-resource override. Resolve and relapse stay in sync with the real incident, subscribers are fanned out to, and webhook events fire. status0 renders active publications with a severity banner, affected-component badges and a collapsible incident history; dash0 gets the page settings, the publish/unpublish block and a dedicated publication editor route. Also exposed as MCP tools (#240)
  • Incidents: "What the probe saw" — a check can opt in to capturing the failing HTTP response, which is persisted when the incident opens or reopens and shown as a diagnostics card. The capture rides a dedicated Diagnostics channel, separate from Output, and is deliberately kept out of the assertion body, so turning it on can never move a verdict (#240)
  • Notifications: PagerDuty (Events API v2) as a notification sender, replacing Opsgenie across the backend, the dashboard and the docs (#240)
  • Notifications: Matrix as an org-level notification integration (#240)
  • checkers: browser checks can run against a remote CDP Chrome (checkers.browser.cdp_url, checkers.browser.chrome_path), with a concurrency cap and a per-region browser capability reported by the workers themselves. Creating a browser check in a region whose workers report no headless Chrome now warns at creation time, and the dashboard surfaces that warning under the region picker (#240)
  • Email: send-mode SMTP checks submit a real probe email and pair it with delivery, with the matching form fields and delivery-pairing UI in the dashboard (#240)
  • Authentication: change your password from the dashboard — an authenticated POST /api/v1/auth/change-password endpoint and a Password card on the account security page, rate limited and sparing the current session (#240)
  • Agents: every agent now reports its build version over the wire. It is stored, exposed in AgentResponse, and shown in the dashboard flagged when it drifts from the server's. Long-revoked agents are purged by the agent_gc sweep, and a second DELETE purges an already-revoked agent immediately (#240)
  • db: a startup migration-integrity guard catches migrations whose content changed after they were recorded as applied — the exact failure that silently skipped migration 013 and left workers.capabilities missing on a running instance. It has a warn mode (db.migration_guard_mode), a self-healing migration 014, and a solidping migrate repair CLI command (#240)
  • results: abandoned probes are reaped into a dedicated abandoned status instead of lingering as created or running. They are excluded from availability, declared in the OpenAPI enums, and rendered as a neutral state in both frontends (#240)
  • observability: slow SQL queries now WARN with a callsite label, and a results row-count gauge refreshes on the aggregation job's cadence. The threshold is configurable through db.slow_query_threshold / SP_DB_SLOW_QUERY_THRESHOLD, defaulting to 500ms (#240)
  • sentry: every 5xx from the handler error funnel is reported, check and job panics carry their identifying tags, and the recovery middleware that the panic path was re-panicking into now exists — mounted inside the request timeout rather than above it. The environment defaults to the run mode, and SP_SENTRY_TRACES_SAMPLE_RATE is bound with an explicit default (#240)
  • Dashboard: the TOTP QR code is rendered client-side instead of relying on a field that was never populated (#240)
  • Dashboard: entity search in the command palette, plus the sidebar pages that were missing from it; a persistent add-check button on the group header; unified Refresh placement and empty states across the list pages; and the SMS mode panel moved to the bottom of integrations, collapsed by default (#240)
  • Dashboard: check-type badges and icons are driven from a single canonical check-type identity registry at every call site, the discovery page included (#240)
  • checkjs: JS sub-checks refuse check types the server has disabled (#240)
  • analytics: serve PostHog capture first-party through /ingest (#235)

Performance​

  • Availability: the per-check availability endpoint walked its five periods in a plain loop, so the call cost the sum of ten DB round trips (~8s measured). It now fans out through a bounded errgroup that writes by index, so the response keeps the requested order, with a period-count cap (#240)
  • Incidents: the incidents list did N+1 enrichment queries — a limit=50 page cost ~900ms. Member enrichment is now opt-in via with=members, and what remains is batched into at most three queries per page regardless of page size (#240)
  • uptime-bar: availability is split into tier-aligned raw and rollup queries, with the raw retention resolved from the live performance parameters and the raw row cap sized from the org's measured probe rate (#240)

Bug Fixes​

  • Email: mail_from is validated as a real address, closing an SMTP/header injection hole (#240)
  • Checks: auto-slug races on create and clone are resolved instead of surfacing a raw 23505 (#240)
  • checkhttp: JSONPath assertions are evaluated on checks that have no body matcher (#240)
  • Notifications: deleting a notification route deletes its contact with it, and dangling routes are cleaned up in the v0.17.0 migration (#240)
  • sqlite: the post-006 unique index survives the 016 table rebuild (#240)
  • API: the results list joins checks so with=checkSlug,checkName is actually populated, and every result status the server emits is declared in the schema (#240)
  • Status pages: the availability-bar tooltip no longer goes stale when moving onto an adjacent segment, and incident failure snapshot blocks get a destructive tint (#240)
  • Dashboard: the 2FA temp token is sent as a Bearer header during login verify, duplicate empty-state CTA buttons are gone from six list pages, picker labels resolve, and SLO slugs auto-generate (#240)
  • blast-radius: the table rendered raw 36-char check UUIDs and overflowed a 375px viewport. Names are hydrated and truncated, rows link to both the child incident and the check, and the missing locale keys are filled in across all four languages (#240)

0.16.2 — 2026-08-16​

(diff)

Bug Fixes​

  • Status pages: the response-time graph of a multi-region check rendered blank. Regions do not sample in lockstep — each worker has its own second of the minute, so five regions checking every minute produce five distinct timestamps per minute. Plotting keyed those timestamps exactly, which turned 500 samples into 500 rows holding one region's value and four gaps, and since a gap in the data is deliberately drawn as a gap rather than as a straight line across an outage, no series ever had two adjacent points to draw a line between. The graph was there; it just had nothing to draw. Samples are now grouped into shared slots sized from the finest sampling interval across the regions, which also repairs the tooltip — it could previously only ever name one region, whichever one happened to own the timestamp under the cursor. A lone sample with gaps on both sides now draws a point, so data can no longer be present and invisible (#233)
  • Status pages: the availability bar spaced its day blocks unevenly. Thirty blocks and a fixed gap cannot divide an arbitrary card width — 30 blocks in a 686px row is 19.97px each — and browsers paint element backgrounds on whole device pixels, so they held the gaps and took the fraction out of the blocks. Measured across 61 widths in both Chrome and WebKit, that is a full pixel of width difference between blocks, and it is worst at a fractional device pixel ratio (browser zoom, or a scaled display), where it lands on roughly every other block. A thin block reads as extra space beside it, which is what made the row look ragged. The bar is drawn as a single SVG now: the blocks are shapes rather than boxes, so they keep their exact fractional geometry and are antialiased instead of snapped, and the measured spread drops from 1.50 to 0.25 device pixels

0.16.1 — 2026-08-16​

(diff)

Maintenance release.

0.16.0 — 2026-08-16​

(diff)

Security — ⚠ Breaking​

  • API: POST /api/v1/orgs/:org/jobs is closed down, and this breaks any script that used it to enqueue a job type other than sleep. The endpoint accepted any registered job type from any authenticated org member, including a read-only viewer. Two of those types are directly abusable: email sends attacker-authored HTML through the deployment's own SMTP sender — a phishing primitive wearing the install's From: address and its SPF/DKIM alignment, to unconstrained recipients — and webhook issues arbitrary server-side HTTP requests with attacker-chosen method, headers and body, i.e. SSRF against cloud metadata endpoints, cluster-internal services and the API's own loopback. The route now requires org admin and accepts only allowlisted job types, the allowlist being sleep alone; everything else is refused with 403. It is an allowlist, not a blocklist, so a job type added later stays closed until someone opts it in deliberately. Nothing first-party used the endpoint for those types — the dashboard's jobs views are read-only, and SolidPing's own transactional mail, test API and schedulers enqueue through the job service directly, which is deliberately not subject to the allowlist and keeps full access to every type. The one first-party caller is solidping jobs create --type …, which is now equally limited to sleep and says so in its help. While here, the handler's status mapping was fixed: a malformed config or an unknown job type was reported as 500 INTERNAL_ERROR, and is now 400 VALIDATION_ERROR; only genuine infrastructure failures remain 500

Security​

  • Billing & entitlements: the #bt= upgrade token appended to the dashboard's upgrade link is now minted with its own dedicated HS256 secret instead of reusing entitlements.billing_inbound_secret. That bearer travels on every service call between the billing service and this one, so a credential with the broadest exposure was also the power to mint an upgrade token for any organization — a leak that should have cost one service call instead granted plan escalation across the whole deployment. The two are now separate parameters, and both ends prefer the new secret while still accepting the old one, so deploy order does not matter. Migration is: ship this (nothing moves — the fallback mints exactly as before, warning once per process), generate one new secret and set it on both sides, confirm the fallback warning has stopped, then turn the legacy path off on billing. Only that last step closes the exposure — the ones before it make it closeable. Setting both parameters to the same value logs an ERROR at boot and still starts, rather than silently collapsing back into the shape being fixed

Features​

  • Checks: a new prometheus check type, in two modes. Scrape pulls a metrics endpoint and grades a named metric; promql runs a query against a Prometheus server and grades the result. Both support graded warning/critical thresholds in either direction, so "under 10 is critical" reads as naturally as "over 90 is critical", and the response body is capped at 5 MB so a runaway exposition page cannot exhaust a worker (#226)
  • Checks: domain expiration is resolved over RDAP, falling back to WHOIS only when RDAP is unavailable. RDAP returns structured JSON with a real date field, where WHOIS is unstructured text whose layout differs per registry — which is why expiry parsing was the fragile part of the check. The lookup method is selectable per check from an Advanced field, and domain checks gained the same warning/critical day tiers the SSL check already had, so "expires in 30 days" can warn long before it becomes an outage
  • Checks: check periods can exceed 24 hours — one week, two weeks and 30 days join the existing intervals. Certificate and domain-expiry checks do not need to run every minute, and the scheduler, aggregation and availability calculations were each proven against long periods rather than assumed to handle them
  • regions: workers self-probe their IPv4 and IPv6 egress and report it, and a region's capability is aggregated from its live workers as a genuine three state — yes, no, or unknown. Unknown never collapses into no: a region served by an agent that predates capability reporting would otherwise be advertised as IPv6-incapable, and users would avoid regions that work perfectly well. A check targeting IPv6 from a region reporting no v6 egress warns and still runs — the capability is advertisory, and nothing about it gates execution. The dashboard surfaces the advertised egress at the point where the region is actually chosen
  • Integrations: incident comments fan out through the notification pipeline, so a comment written in one place reaches the others instead of being visible only where it was typed. Slack comment ingestion is explicit by default — an incident channel is a conversation, and silently promoting every message in it to an incident comment is rarely what anyone wants — with an explicit /comment command on both Slack and Telegram, the Telegram one resolving org-qualified incident references
  • SMS: SMS and voice can be configured at the instance level, in two modes. Either the deployment provides a shared sender that organizations use as-is, or an organization brings its own provider credentials and overrides it. Resolution order between the two is pinned by tests rather than left to precedence-by-accident. Adds an OVH SMS client with its delivery-receipt endpoint, and instance-level spend guards that are checked before the per-org reservation — a guard applied after the reservation is a guard that has already let the spend through. The guards also cover verification and test sends, which are the two paths most likely to be used to probe a deployment's limits
  • SMS: outbound SMS carries the A2P 10DLC opt-out disclosure required for US messaging
  • Billing & entitlements: the SaaS free-tier check rate rises to 10 per minute
  • Dashboard: form controls have their own --control surface token instead of rendering the exact same colour as the page behind them, and the segmented control was extracted into a shared SegmentedControl primitive — a raised pill on a recessed track, so the active segment reads as raised rather than as the darker one
  • Status pages: the public status page has a dark mode, which the rest of the site has had all along. It follows the system preference on first paint and can be overridden from a header toggle that persists the choice. The default is resolved before first paint rather than after hydration — a status page is the one surface people open at 3am specifically because something is broken, and a white flash on a dark phone is a poor way to greet them. theme-color metas are set for both schemes so the browser chrome matches, and the response-time colouring and the subscribe widget's success and error states were moved onto design tokens so they stay legible in both themes instead of being hard-coded for light (#224)
  • Status pages: response-time graphs plot one series per region instead of averaging every region into a single line. A check running from three continents was previously drawn as one curve, which hid exactly the thing a multi-region check exists to reveal — that one region is slow while the others are fine. Grouping happens server-side within the same point budget, so a page with many regions stays as cheap to render as before, and the incident strip rolls a period up by worst-status-wins rather than by whichever result happened to be last
  • Status pages: the status page hero, header and typography were restyled to match the rest of the product
  • Integrations: Telegram contacts paged for an incident are now told when it ends. Being woken by an alert and never hearing that the outage resolved is the failure mode that teaches people to stop trusting the pager. The resolution notice is delivered to each chat that was actually paged, anchored to the original message's thread, and is exempt from the sweep that cancels an incident's pending ack/snooze/resolve jobs — a resolution notice cancelled along with the paging cycle it belongs to would never be sent at all
  • Integrations: a single Telegram chat linked to several organizations no longer routes commands to an arbitrary one. Incident references are rendered and parsed org-qualified, commands and ack button presses are dispatched across every linked org, acknowledgements are attributed to the right one, and contact lookups are ordered deterministically (oldest link first) instead of depending on row order
  • Integrations: Telegram incident alerts carry a 🔎 View button next to ✅ Acknowledge instead of burying the dashboard link in a text line at the bottom of the message. A URL button needs no callback verb, so this adds no new dispatch surface. Acknowledging or resolving an incident now edits the message down to the View button alone rather than stripping the keyboard entirely — the ack action becomes stale, but navigating to a resolved incident's history does not. The /incidents listing and /incident <#ref> detail replies use the same keyboard
  • Email: every transactional email is rendered through the shared template system. Five send sites still hand-rolled their own strings — the escalation-policy alert (plain text only, and it addressed the incident by its raw 36-character UUID), both test-send emails, the member paging nudge, and the status-subscriber confirm/update mails, which carried an entire parallel HTML builder duplicating what the base template already provided. All five now render through the formatter with proper HTML and text parts, and every template has a preview fixture so it can be iterated on visually
  • Email: incident emails show the incident number. Incidents have carried a short per-org reference (#42) since v0.15.0, surfaced in the dashboard, Slack and Telegram, but the four incident email templates never received it — so an email was the one alert you could not quote back at anyone. It now appears in both the subject and the details table, in the HTML and plaintext parts alike
  • Dashboard: notification lists say which event was notified. The incident detail page's Notifications card, the notification detail page, the incident event log and the integration detail page's deliveries table all previously showed either nothing or a bare incident.created code string, so a resolved incident's card was several near-identical rows you had to click through one at a time. Each event type now has one canonical emoji and colour, applied consistently across the dashboard and aligned with what the Slack, Teams and Telegram surfaces already send, so one event reads the same everywhere. The emoji and tint are layered on the translated label rather than replacing it
  • Dashboard: timestamps across the incidents and jobs pages use a shared TimeAgo component — relative by default, exact time on hover, click to copy. A relative timestamp alone ("46m ago") is unusable for incident analysis, where the absolute time is what you correlate against everything else

Bug Fixes​

  • Checks: checkers are the single owner of their default check slug, so two check types can no longer both claim the same one and silently de-duplicate into a surprising name. checkdnsbl and checksip had no default slug at all and now have one
  • SMS: the instance spend guards are evaluated before the per-org reservation rather than after it, and they now apply to verification and test sends too — both paths previously bypassed the instance ceiling entirely
  • Dashboard: check-type help links resolve through a docs anchor map keyed by the raw backend type string rather than the frontend's CheckType union, so a type whose backend name differs from its frontend spelling links to its own documentation section instead of the top of the page
  • Dashboard: chart tooltips, the trimmed top bars and the event dots on response-time graphs (#225)
  • custom-domains: a status page served on a custom domain now answers /status0 SPA routes and /api/mgmt/version, so deep links and the version probe work there as they do on the primary host
  • TLS edge: an instance refuses to serve a host it no longer owns rather than continuing to answer for it
  • Status pages: creating a status page without a slug generates one instead of failing validation

0.15.1 — 2026-08-14​

(diff)

Bug Fixes​

  • Dashboard: a maintenance window attached to no checks no longer reads as a neutral "0 checks". Membership resolves purely through the join table, so a window with nothing attached suppresses nothing — it is inert, not org-wide. Rendered in muted grey it looked like an ordinary count, which meant the one state an operator most needs to catch — a window created and then never pointed at anything, silently paging straight through the planned work it was supposed to cover — was the state the UI drew most quietly. It is now an amber warning, and a window whose suppression is live is marked with the pulse dot rather than a static badge (#220)
  • Dashboard: the escalation policy list stopped hiding the two things you actually open it to check. The API already returned the step count and the per-check and per-group usage, and the page rendered none of them — its Description and Repeats columns were, for most policies, two columns of em dashes. There are now Steps and Used by columns, a policy that no check or group references is called out as unused, and a policy with zero steps — attached to checks but paging nobody — carries the same amber warning as an empty maintenance window. Repeat intervals read as a duration ("every 15 min") instead of a raw second count (#220)
  • Dashboard: the events audit log is scannable. Every row previously carried a full locale timestamp (8/14/2026, 12:38:30 PM), an icon, and a badge in the same neutral outline regardless of what had happened — so a page of fifty events offered no way to find the one that mattered without reading all of them. Timestamps are now relative with the exact time on hover, and each badge is tinted by event family: failure and escalation red, recovery emerald, operator acknowledgement amber, configuration blue, onboarding violet. The tint is layered on the translated label rather than replacing it, and an unrecognized event type falls back to the plain badge instead of inventing a colour (#220)
  • Dashboard: the dashboard and every list view — checks, incidents, integrations, status pages, on-call, escalation policies, maintenance windows, events — now share one list surface: a card-elevated table on new neutral elevation tokens, a header tinted a step down from its rows, a hover tint that makes a whole row read as one target, and a single empty-state treatment so a list that renders nothing says so instead of showing an empty frame. Each new primitive is catalogued in the in-app design reference alongside the exact import line, so the next page starts from the shipped pattern rather than reinventing it (#220)
  • Dashboard: two list tables were unusable below roughly 700px. The card wrapper clips to its own radius, so without an inner scroll container the trailing columns and the per-row edit and delete buttons were not merely off-screen but unreachable on a phone; separately, uncapped schedule text pushed the maintenance window table wider than the viewport and scrolled the page body sideways (#220)
  • Dashboard: the production build works again. Four imports and a helper left behind by the previous UI pass were unreferenced, which fails tsc and so failed bun run build — the type check runs as part of the build, so this broke the build outright rather than merely warning (#220)
  • Dashboard: the events page keeps its type filter in the URL instead of component state, so a filtered view survives a refresh, can be bookmarked, and moves under browser back/forward like the rest of the dashboard (#220)

0.15.0 — 2026-08-14​

(diff)

Features​

  • Incidents: every incident now has a short, human-scale reference — #42 — alongside its UUID. Nobody was ever going to type a 36-character UUID into a chat window on a phone, which is what blocked any typed incident command. The number is a per-organization, monotonically increasing column assigned at creation, concurrency-safe on both Postgres and SQLite, and existing incidents are backfilled oldest-first so #1 really is the org's first incident. Soft-deleted incidents keep their number and numbers are never reused, so #42 means one incident forever — a reused number would turn an ack typed from an old alert into an ack of somebody else's outage. The reference is surfaced everywhere it is useful: the API, the dashboard's incident list and detail, Slack messages and Telegram (#217)
  • Integrations: the Telegram bot is no longer notify-only. Alert messages carry an inline Acknowledge button — press it and the incident is acked through the same service path Slack uses, a toast confirms it, and the original message is edited in place to "✅ Acknowledged by … at …" with the button removed. Pressing it again on an already-acked or resolved incident reports the current state instead of erroring. Alongside the button there is a typed command set — /status for one-line org health, /incidents for the open list with a per-incident ack button, /ack [#ref] (bare, with a single open incident, acks it; with several it lists the candidates and acks nothing), /incident <#ref> for the latest detail, and /help — registered via setMyCommands so they autocomplete in the client. Commands and button presses alike are refused in chats that have not been linked to an account (#217)
  • Incidents: an incident now records what caused it, not just that it happened. The details column existed but nothing ever wrote to it — meanwhile the Slack formatter was already reading failure_reason out of it and, finding nothing, captioning every alert with the literal "Check failed". The first failing result is now snapshotted into the incident at open — a copy, not a reference, because the aggregation job deletes raw results and a pointer to a deleted row is worthless a week later — capturing the standardized error, status, region, duration and the checker's own output, size-capped so a pathological payload cannot bloat the row. Reopening records the relapse under last_failure without disturbing the original cause. The dashboard's incident detail grows a "First failure" card (and a "Latest relapse" variant), and both Slack readers light up as a side effect (#217)
  • Agents: the TLS edge can chain to a second instance. The deployed topology has a single dynamic-custom-domain slot at the edge — the proxy TCP-passthroughs unknown SNI to one SolidPing instance, which issues certificates on demand for its own verified domains and dead-ends everything else. An instance can now forward a connection for a host it neither reserves nor serves to a configured upstream, prefixed with a PROXY protocol v2 header carrying the original client address, so two instances can share one edge. The fork happens strictly below TLS termination — the first hop never holds the second's certificates — by peeking the ClientHello for SNI (handling a hello split across TCP segments) and replaying every buffered byte onward. The plaintext :80 listener gets the same treatment keyed on the Host header, without which the downstream could never solve an HTTP-01 challenge. Fail-closed throughout: no SNI, a malformed hello, or a peek timeout all stay local rather than being forwarded on a guess, a chain misconfigured into a cycle is refused rather than ping-ponging until file-descriptor exhaustion, and an unreachable upstream closes the connection instead of falling back to terminating a host we already decided is not ours. Off unless an upstream is configured (#217)

Bug Fixes​

  • Dashboard: the check detail page listed the same regions in two different orders — the Response Times chart showed them in whatever order they first appeared in the results window, while the Recent Results filter sorted them alphabetically by slug. Because org-relative private-region slugs start with @, which sorts ahead of letters, a custom region jumped in front of the standard ones in the filter but not in the chart. Both rows now share one canonical order: standard regions first, then custom ones, alphabetical by display name within each group (#217)
  • Dashboard: the check detail page no longer shows a documentation icon in its header toolbar. The check edit page already links to the same page, which is where check types are actually configured; on the detail page it was redundant toolbar noise (#217)

0.14.0 — 2026-08-13​

(diff)

Features​

  • Notifications: Slack channel alerts can now mention the on-call person. A new user_integration_identities table records who each org member is on a given Slack workspace, populated by an automatic users.lookupByEmail match after OAuth and re-runnable from the integration panel, with a manual override for anyone the match misses. When the new Mention the on-call person in alerts switch is on, incident.created and incident.escalated prepend <@id> for every human target on the escalation policy's first step — the schedule-resolved on-call plus any direct user targets, deduplicated. Resolved and reopened messages stay mention-free, a member without a mapped identity degrades to a plain-text name, and a failure to resolve mentions never blocks the alert itself. The switch defaults on for newly created Slack integrations and stays off for existing ones (#215)
  • Notifications: admins can finally see who is actually reachable. The members page gains a paging-coverage column showing each member's channels and whether they are verified, with an explicit "email fallback only" state, and the same warning badge appears next to anyone rostered on an on-call schedule or named as a user target in an escalation policy. The coverage endpoint deliberately exposes only channel types and verified flags — never phone numbers or handles. Admins can also pre-provision a phone or WhatsApp contact for a colleague in unverified state and send a "set up your paging" nudge; an admin can never create a verified contact nor flip one to verified (#215)
  • Agents: private regions are now stored org-relatively (@aws-paris instead of @acmetech/aws-paris), so renaming an organization no longer strands its agents. Previously the org slug was baked into every stored region string at write time, and a rename left pre-existing agents and checks matching each other on the old spelling while the API advertised the new one — every check created afterwards sat in validating forever with nothing surfaced. A migration rewrites every stored region across agents, checks, jobs, org defaults, enrollment tokens and historical results, collapsing duplicate spellings, which also retroactively repairs installs already broken by a past rename. The legacy @<org>/<slug> form is still accepted on input for the org's own current and previous slugs, and rejected for anyone else's (#215)
  • Agents: the tlsedge listeners speak PROXY protocol (v1 and v2), so custom domains behind a TLS passthrough keep the real client IP. Without it every request through the passthrough appears to come from the proxy's own address and per-IP rate limiting silently collapses. Gated behind acme.proxy_protocol with an explicit trusted-CIDR list: headers from a trusted source are honoured, headers from anywhere else are ignored rather than trusted, and enabling the feature with an empty CIDR list fails at startup rather than trusting everyone (#215)
  • reliability: the server now fails fast on a structural database fault instead of retrying forever. A missing table, a corrupt file or a moved database is classified once in a shared db/dbfault package — by SQLSTATE on Postgres and result code on SQLite — logged as a single clear line, surfaced as a 503 on /api/mgmt/health, and then shuts the process down so a supervisor can restart it and re-run migrations. Transient faults (dropped connections, serialization failures, lock timeouts, too_many_connections, admin shutdown) are unchanged and still retry (#215)
  • Dashboard: small documentation links across the dashboard. A new DocsLink primitive renders a discreet icon button in the page header, wired to the matching page under /docs for checks, incidents, on-call, status pages, custom domains, maintenance windows, private locations, integrations, API tokens, MCP and organization authentication. Pages with no genuinely relevant docs page simply don't get one (#215)
  • Dashboard: the command palette now has entries for the Organization and Account sections themselves, not just their sub-pages, so typing "organization" or "account" finds them (#215)
  • Dashboard: the check detail page uses a single region parameter shared by the chart, the Recent Results table and the duration stats, instead of separate graphRegion and resultsRegion keys that had to be set one at a time. Selecting a region anywhere scopes the whole page, and switching regions re-renders in place — no chart remount, no reload, no lost zoom (#215)
  • Dashboard: private-location agents show "Last seen" as a live relative time ("5m ago") that ticks in place, with the exact local timestamp on hover (#215)

Bug Fixes​

  • jobs: a job runner that hit a persistent, instantly-failing error used to spin at CPU speed and log every attempt — one incident filled a 460 GB disk in seventeen hours at ~30 GB/h. Consecutive failures now back off exponentially from 100 ms to a 30 s cap with jitter, and repeated identical errors are logged at exponentially spaced counts with a single summary line on recovery. A worker parked at the cap still shuts down promptly, and a runner backing off no longer counts itself as available (#215)
  • jobs: a runner no longer retires itself when a job's context is cancelled. The terminal status write runs on a detached context that the soft-delete watcher can cancel, and the resulting error was indistinguishable from the worker's own shutdown — a narrow race could silently shrink the pool on a perfectly healthy server (#215)
  • Notifications: an admin adding a paging contact for a member could silently de-verify a number that member had already verified, if the contact existed with no notification route attached. The duplicate check now looks the contact up by type and value rather than through the route join (#215)
  • Dashboard: a check group containing zero checks showed three skeleton placeholder rows instead of an empty state — and on an organization with more checks than one page, kept showing them indefinitely, because the group deferred to the still-loading infinite-scroll stream rather than the group's own server-side count (#215)
  • Dashboard: the account API-tokens toolbar wraps on narrow screens instead of squeezing the search field down to a few characters (#215)
  • Agents: the e2e harness no longer tears down the database while a server it started is still running, and a spawned server can be told to exit with its parent instead of being orphaned (#215)

Documentation​

  • Agents: the private-locations identity documentation no longer tells operators to run base64 inside the agent container. The shipped image is distroless — no shell, no base64, no tar — so kubectl exec, kubectl cp and fly ssh console all fail, which left SP_AGENT_PRINT_KEYS (which prints private key material to stdout, and thence to the log pipeline) as the de-facto only Kubernetes path. The docs now describe the two supported shapes — SP_AGENT_KEYS from an env var or a Secret — and show a working extraction route: a throwaway enrollment Pod sharing an emptyDir with an ordinary sidecar, piped straight into the Secret so the key material never lands in a terminal or a log (#215)

0.13.0 — 2026-08-12​

(diff)

Features​

  • orgs: deleting an organization no longer logs the owner out. DELETE /orgs/:org now answers 200 with a replacement session scoped to an organization the caller still belongs to — omitting organizations entirely when none survives — and the dashboard adopts it, so the owner stays signed in and lands somewhere real instead of being bounced to the login screen holding a token for an organization that no longer exists. Co-members of the deleted org have their sessions revoked, and a stale token is rejected by /auth/me rather than half-working (#212)
  • Notifications: every ready notification method now carries the Test button, and the visibility rule became generic. The dashboard used to decide per contact type — which is how a connected Telegram contact ended up as the one method without a Test button even after the backend learned to send Telegram tests. The row now shows the button for any route whose contact is ready to be paged (types with a setup round-trip — phone, WhatsApp, Telegram — once verified or connected; every other type, including future ones, from creation), and the backend matches: SMS tests ride the org's default Twilio connection like a verification code does, WhatsApp tests go out through the approved alert template with self-describing values, and a contact whose setup round-trip is incomplete is refused instead of tested. A structural test pins the rule by walking every pageable contact type through the test dispatcher and failing on any that reaches the "provider not configured" default (#212)
  • Dashboard: a design-system pass over the surfaces that had drifted or were carrying browser defaults. Status badges and alerts trade saturated fills for a ~15% tint of the status color with the color itself as text, through new --status-*-foreground tokens that darken in light mode and lighten in dark — so a table row no longer shouts and the real alarm has somewhere to escalate to. Toasts stop rendering all 200 call sites identically monochrome: the surface stays neutral and the meaning lives in the icon hue. The 104 font-mono call sites get JetBrains Mono instead of whatever the OS shipped, with tracking-tight tabular numerals for large figures, and light-mode cards are lighter than the page rather than darker, so a raised surface reads as raised (#212)
  • Documentation: a Data Retention page tells self-hosters how long their data actually lives — that results roll up raw → hour → day → month with the finer rows deleted, what the 24h/7d/2mo defaults mean for charts and status pages, which knobs tune them (the three SP_PERFORMANCE_AGGREGATION_RETENTION_* variables or the Server → Aggregation tab, env winning), and the rule that bites: raising retention never restores already-rolled-up history, so it has to be raised before the history is needed (#212)

Bug Fixes​

  • Availability: a 365-day or year-to-date availability window reports on the whole window instead of silently covering only the most recent ~2 months. The window query never read the terminal month rollups, and with the tightened default retention everything older than the day tier's two months lives only there; the union now includes the month tier — the tiers stay disjoint by construction, so nothing double-counts — and the endpoint's 12-month lookback rejection, a data-horizon guard the same change made obsolete, became a 10-year input-sanity cap, so multi-year windows are now valid (#212)
  • Telegram: the notifications page's Test button reaches Telegram. Test dispatch knew email, Slack and web push but had no Telegram case, so it fell through to a generic "provider not configured" 422 — on the one button a user presses to confirm the setup they have just finished, while real escalation delivery through the same contact worked. Sending a test needs only the bot token, matching alert dispatch, so it also works on an instance whose bot username is not known yet (#212)
  • Telegram: a bot username derived at boot is written down even when the startup lookup missed it. The synchronous resolver gets one getMe bounded to three seconds so it cannot delay boot; when that call lost a race with a cold DNS cache the username stayed unknown and nothing was persisted, so the connect surface answered "telegram is not configured" on every later request while the asynchronous bootstrap — which has a far more generous budget — had already succeeded and logged "Telegram bot ready". The bootstrap now persists what it learned, so the next restart resolves the username from the database with no network call at all (#212)
  • Dashboard: the org-admin guard no longer strands the deleter on the dead slug. The organization layout bounces a non-admin to /orgs/$org during render, and right after an owner deleted their last organization the adopted replacement session is org-less and carries no admin role — so the guard fired on the very next render and won the race against the deliberate navigation to /no-org, landing the user on the deleted org's dashboard where every query 404s. The guard is skipped for an org this tab just deleted, which is exactly the window in which its verdict is meaningless (#212)
  • web: the favicon and manifest resolve from the app base instead of the current SPA route. The links were bare relative hrefs, so on a nested route the browser asked for /dash0/orgs/x/checks/favicon.svg, got the index.html SPA fallback where it expected an image, and fell back to the default icon. Anchoring them at / lets the build rebase them onto the app base, and an e2e test asserts the icon resolves from a deep route (#212)

0.12.0 — 2026-08-10​

(diff)

Features​

  • Telegram: Telegram joins the alert channels, driven by an instance-owned bot. A user connects by following a one-time deep link that opens a chat with the bot, and escalations are dispatched there with per-incident threading, so a single incident stays one conversation rather than a stream of unrelated messages. The dashboard's notifications page hosts the connect and reconnect flow, and a public capability flag lets the frontend hide the channel entirely on an instance that has no bot configured (#209)
  • Telegram: a bot token is the only setting an operator has to provide. The bot's username and the webhook secret are derived from the token at boot and persisted, the webhook is re-registered on every start so a rotated secret converges on its own, and SP_TELEGRAM_ENABLED became a tri-state switch — unset means "on if a token is present", leaving an explicit true/false as a deliberate override (#209)
  • WhatsApp: WhatsApp alerts carry a button linking straight to the check, so a page can be acted on without hunting for the check in the dashboard (#209)
  • embed: the embedded status widget links back to the status page by default, with a data-link="false" opt-out for pages that would rather not navigate away. The dashboard's widget card exposes it as a toggle (#209)
  • Heartbeat checks: a heartbeat ping can report how long the job it guards actually took — durationMs in the structured body is threaded into the recorded result's duration instead of being discarded (#209)
  • Checks: the checks list serves each check's last status change directly, so "how long has this been up" no longer has to be reconstructed from result history (#209)
  • Dashboard: each row of the account Organizations list gets a direct Settings shortcut, replacing a switch-then-navigate detour (#209)

Bug Fixes​

  • Checks: the checks list no longer burns a CPU core. Last-result lookups descend a per-check index instead of sequentially scanning the results table, a live results event refreshes only what changed rather than refetching the whole list, and the steady-state poll runs every 10s as intended. Regression tests assert the absence of sequential scans and of temp-file spill, each with a positive control (#209)
  • API: getCheck declares its with query parameter in the OpenAPI spec — the endpoint accepted it but never advertised it, so generated clients could not pass it (#209)
  • Telegram: an escalation with no severity set can page Telegram, instead of being silently dropped (#209)
  • Telegram: a connect attempt from a group chat, or with an empty chat id, is refused rather than producing a contact that can never be delivered to (#209)
  • Telegram: a bare SP_TELEGRAM_ENABLED= is read as unset rather than as an explicit "off" (#209)
  • Documentation: the WhatsApp template instructions match what Meta actually accepts, and the placeholder code spans stay on one line so MDX can parse them (#209)

0.11.0 — 2026-08-09​

(diff)

⚠ BREAKING CHANGES​

  • members: the member-management write routes (POST/PATCH/DELETE /api/v1/orgs/:org/members) are now admin-only. Any member of an organization — including a read-only viewer — could previously add members, change other members' roles (including promoting themselves to admin) and remove members; there was no admin check on the routes or in the handlers. Reads stay open to every member, because the escalation-policy editor and the member picker need them. An integration that wrote members with a non-admin token now receives a 403 FORBIDDEN (#204)
  • CLI: sp auth login now uses the RFC 8628 device-authorization flow, replacing the loopback-browser flow. A host with no browser — a server, a container, an SSH session — can authenticate by visiting a URL elsewhere and entering a code, and the CLI no longer opens a local listening port to catch a redirect (#204)
  • orgs: deleting an organization is owner-only and requires confirming the slug. Organization creators become owner, and every role gate is now hierarchy-aware, so an admin no longer inherits the ability to remove the organization or to touch an owner (#204)
  • Integrations: saving a Twilio connection now verifies the credentials against Twilio before persisting. A wrong Account SID or auth token — or one belonging to a different region than the connection declares — is rejected at configuration time with a VALIDATION_ERROR rather than failing silently at 3 a.m. on the first page (#204)

Features​

  • orgs: an owner role sits above admin, with the privilege hierarchy enforced against the live membership row rather than the JWT claim. Only an owner may grant ownership, modify or remove another owner, or delete the organization, and the last owner cannot be removed (#204)
  • orgs: organizations get an editable profile — display name, uploaded logo, and a slug rename that keeps working. Previous slugs are stored and redirect across every org-scoped API group, so renaming no longer breaks dashboard links, status pages, badges or embedded widgets; aliases are released at the database choke points and replaced logo files are retired (#204)
  • orgs: a user who already belongs to an organization can create another one. A new Organizations tab in the account section lists every org with its logo, slug and the user's role, marks the current one, switches between them, and hosts the create form — previously reachable only by hand-typing /no-org or calling the API (#204)
  • Status pages: a status page can now be embedded anywhere. A public summary endpoint exposes the server-computed rollup, an SVG badge endpoint renders it as an image, and a JavaScript widget served from /embed/v1/widget.js drops a live status block into any page — with data-force-status and data-size attributes, a live preview, size selection and label overrides in the dashboard's appearance settings (#204)
  • CLI: the device-authorization flow is backed by RFC 8628 endpoints with org-bound consent, persisted request storage on both database dialects, and a dashboard consent page with an org picker (#204)
  • Checks: a check can be pinned to an address family with a shared ipVersion option, so an IPv4-only or IPv6-only probe is a deliberate choice rather than whatever the resolver returned. Invalid, unsupported and tunneled values are rejected at write time, the dashboard shows which family the probe actually used, and Better Stack imports carry ip_version across with a warning about its both-families default (#204)
  • Checks: HTTP checks gain verifySsl and followRedirects options, exposed as dashboard toggles and mapped by the importers instead of being warned about and dropped (#204)
  • Integrations: a Twilio connection can name its region, so an account provisioned in Ireland (ie1) or Australia (au1) works instead of every request landing on US1 and failing authentication. The region is validated by format rather than an allowlist, so a region Twilio adds later needs no code change, and it resolves the API base on all three paths — escalation SMS, escalation voice, and phone-contact verification (#204)
  • Authentication: federated-login and Slack organization joins go through one shared admission policy, so who may be admitted into an org is decided at a single chokepoint rather than per-provider. Slack workspace members are admitted into their linked org through it, and the Slack app install routes through the same gate (#204)
  • Billing & entitlements: the billing service authenticates by signing its requests — HMAC-SHA256 over timestamp, method, path and body digest — with independent key sets per direction, so keys rotate without a lockstep restart. The legacy static bearer still works but is now gated and logged as deprecated (#204)
  • config: SP_NODE_ROLE accepts a comma-separated list, so one node can run api and jobs without also running checks (#204)
  • Documentation: the documentation site gets offline local search (#204)

Bug Fixes​

  • Checks: an address-family failure now reports the same status on every check type, instead of surfacing differently depending on which checker ran (#204)
  • orgs: a deleted organization's internal checks are stopped along with it (#204)
  • Dashboard: editing an organization's name no longer rewrites its slug. The slug is a load-bearing address, so moving it is now always something the user typed on purpose rather than a side effect of retitling (#204)
  • Slack: the Slack auto-join opt-out fails closed, and how to set it is documented (#204)
  • migrations: the unreleased migration is named 010_v0_10_0 rather than v0_9_0, so a consolidated release-cycle migration is not silently skipped against a database that already recorded the older number (#204)

0.10.0 — 2026-08-08​

(diff)

Features​

  • checkers: ClickHouse health checks over the native protocol (#203)

0.9.0 — 2026-08-07​

(diff)

Features​

  • CLI: config-as-code for checks. sp checks export / import round-trip a whole check catalog as YAML, sp checks diff shows what an import would change before it runs, and sp checks validate accepts a complete export document offline — no server, no credentials — so a catalog can be linted in CI (#198)
  • config: SP_NODE_NAME overrides a worker's identity instead of deriving it from os.Hostname(). Worker identity becomes a deployment decision rather than an accident of the container's UTS namespace, which unblocks running check workers under Kubernetes hostNetwork: true — there the pod's spec.hostname is ignored and the node name, dots and all, is rejected by the workers.slug constraint. It also fixes silent slug collisions: two workers whose hostnames shared their first 15 characters previously collapsed onto one workers row and fought over it. The check worker and the job worker now share one identity helper, and a truncated hostname logs a warning naming the resulting slug (#198)
  • config: an illegal worker slug now fails fast at startup with a message naming the offending value and SP_NODE_NAME, instead of surfacing as an opaque Postgres constraint violation after the worker has already started and silently failed to register (#198)
  • Documentation: a Tour page that shows the product in motion. A regenerable Playwright pipeline (make showcase) drives the real dashboard to produce screenshots and an AV1 screen recording of the create-an-HTTP-check flow, so the docs site finally shows what SolidPing looks like instead of only describing it. The media regenerates on demand rather than rotting as the UI changes (#198)

Bug Fixes​

  • Agents: agent private keys are never written to logs or stdout. Key material was previously echoed during agent bootstrap; printing it now requires opting in explicitly with SP_AGENT_PRINT_KEYS. A malformed SP_SYSTEM_AGENT_ENROLLMENT_TOKENS entry is no longer echoed into the logs either (#198)
  • Checks: ValidateDocument no longer mutates the document it was handed, so validating an export could no longer alter it before import (#198)
  • e2e: end-to-end specs no longer silently test the wrong server. Specs across the dashboard and the public status page read a dead E2E_API_BASE variable or a hardcoded localhost:4000, so they hit the local dev loop regardless of E2E_BASE_URL — a suite pointed at a test server was quietly validating something else entirely. Both suites now derive their origin from a shared fixtures module, with a lint guard in each config so it cannot regress (#198)
  • e2e: session-sensitive tests get a dedicated login instead of mutating the shared worker session, removing a source of cross-test flakiness (#198)
  • e2e: the kubernetes-cluster spec cleans up after itself when an assertion fails, instead of leaking state into later tests (#198)

0.8.0 — 2026-08-05​

(diff)

Features​

  • Status pages: per-page availability colour thresholds. Each status page can now set its own green/amber floors instead of everything being judged against a hardcoded 99.9/99.0, via a new typed settings JSONB column on status_pages. The public payload always exposes the resolved effective values, so consumers never need to know the defaults. Badges deliberately stay on the global defaults — they are check-scoped and have no page context (#196)
  • Status pages: small-bucket availability calibration. A bucket with exactly one failed sample now renders at worst amber, never red; red requires at least two failures. This fixes the cliff where a single failed minute painted a whole hour red on 1-minute checks, so bar harshness reflects incident severity rather than check frequency (#196)
  • Dashboard: list-page search boxes sync to the URL as ?q=. Filtered views on checks, status updates, status pages, maintenance windows, integrations, escalation policies and dependencies are now shareable and bookmarkable, and survive a reload or back-navigation (#196)
  • Agents: fleet-wide Agents view on the server page, backed by a new superadmin GET /api/v1/system/agents. Platform-operated kind='system' agents belong to no organization and were previously listed nowhere — visible only by querying the database by hand. Includes a staleness cue for agents unheard-from for more than five minutes, since the GC only retires them after seven days (#196)
  • slugs: entity slug maximum raised from 20/40/50 to 100 characters, consistently across checks, check groups, status pages and severities. Long descriptive names are no longer rejected or silently truncated. Organization slugs (a URL path segment and JWT claim) and private region slugs are deliberately unchanged (#196)

Bug Fixes​

  • checkers: retry transient DNS failures instead of burning the whole check budget (#194)
  • db: widen the slug length CHECK constraints that shadowed the application-level limit. Both dialects capped slugs at 40/50 characters in the database, so a longer slug passed validation and then failed at write time (#196)
  • Dashboard: stop the new ?q= write-back from looping on a logged-out deep link, which nested returnTo one level deeper on each pass until the renderer hung (#196)

Miscellaneous Chores​

  • fly: deployment config for the Tokyo (jp-1) platform check agent, with identity pinned via SP_AGENT_KEYS so the agent row stays stable across deploys (#196)

0.7.1 — 2026-08-04​

(diff)

Maintenance release.

0.7.0 — 2026-08-04​

(diff)

Two batches landed in this release: #170 (1184b15) and #175 (9ffd436). The headline is that a status page can now live on your own domain over HTTPS with no external proxy and no second DNS record, checks can be read as hosts rather than as a flat list, and alerting reaches phones — SMS, voice, and WhatsApp.

Features​

Status pages​

  • custom domains + TLS: custom domains are now single-CNAME with automatic HTTPS — mode-aware verification (shared/token), a new DB-backed tls_storage layer (migration 009_v0_8_0), and an in-server ACME edge (certmagic, Let's Encrypt, on-demand gate on :80/:443) that issues, persists and reuses certificates without an external TLS proxy; dash0 shows a certificate-status chip on the custom-domain field. The external-proxy path is kept as an alternative rather than removed. (#170, #175)
  • appearance (custom CSS): status pages can now be visually customized — a new dash0 route (/orgs/:org/status-pages/:uid/appearance) pairs a monospace CSS editor with a live preview iframe that renders through the actual production status0 page, updated via a 300ms-debounced, origin-checked postMessage (no server round-trip). customCss is exposed end-to-end (DB column, storage services, API DTOs, CLI --custom-css/--custom-css-file, MCP tools), capped at 64 KB with @import rejected (external url() stays allowed), and rendered on the public page as a React text child so a stray </style> can't break out. Documented with the full CSS variables theming API (--brand, --background, --foreground, status colors, .dark, …) and covered by Playwright E2E. (#175)
  • appearance (theming hooks): custom CSS can now retarget the page logo and the version string, which previously had no stable hook to style against. (#175)
  • group resources: a status page can publish a check group as a single resource, so a host monitored by several checks reads as one line to subscribers instead of leaking your internal monitoring topology. (#175)

Checks, groups, and hosts​

  • check groups (status rollup): groups now carry an aggregated status, so a host monitored by several checks can be read as one unit rather than as N independent signals. (#175)
  • checks list (collapsed groups): grouped checks collapse into a single status row — one dead host no longer reads as four unrelated failures. (#175)
  • checks by host: a new view organizes checks by the host they probe, matching the way failures actually correlate in practice. (#175)
  • check groups (slug): the group slug is now editable from the dashboard, so DevOps scripts that address groups by slug are no longer stuck with whatever the group was first called. (#175)
  • heartbeat checks: the heartbeat token can now be viewed and regenerated from the check page, instead of being visible only at creation. (#170)
  • importers: import existing checks from Gatus, Better Stack, and Uptime Kuma, so moving to SolidPing doesn't mean re-entering a monitoring estate by hand. (#175)

Alerting and integrations​

  • SMS / voice: new alert channels that can actually wake someone up — SMS and voice call, alongside the existing chat and email destinations. (#170)
  • WhatsApp: a WhatsApp alert channel driven directly through Meta's Cloud API — no BSP in the middle. Monthly send volume is metered and surfaced on the org Usage page. (#175)
  • Microsoft Teams: a Teams notification integration, plus a Slack-grade Teams bot with two-way interaction rather than one-way webhook posts. (#175)
  • Escalation policies: the user picker now shows each user's email next to their name, so same-named users are distinguishable when building a rotation. (#175)

Private locations​

  • system agents (fly.io): cloud/platform workers are generalized into platform-operated "system agents" alongside the existing customer-managed deported agents. Enrollment tokens for platform agents are reconciled declaratively from SP_SYSTEM_AGENT_ENROLLMENT_TOKENS (not mintable through the org-admin routes — dropping the fly secret is the revocation path), a new self-rescheduling agent_gc job retires silent kind=system agents after a configurable window (default 7 days) and prunes consumed reconnect nonces, and server-side EWMA cost/delay accounting is now persisted for results submitted over the agent transport, matching the in-process worker's behavior. Adds a fly.io deploy reference (deploy/fly/) and an ops runbook. (#175)

Platform​

  • analytics: PostHog is now wired into both the backend and the dashboard, so product usage is measurable instead of guessed at. (#175)
  • Documentation: llms.txt and llms-full.txt are served at the conventional root path (/llms.txt), not just under /docs, so crawlers find them where they expect (GitHub issue #183). (#183, #175)
  • Billing & entitlements: maxCustomDomains was enforced but invisible — it now appears on the org Usage page and in the wiki, and a cap of 0 renders as a real saturated 0 / 0 row rather than falling through to "Unlimited". (#175)

Bug Fixes​

  • dashboard (check stats): the dashboard's check counters were wrong for any org with more than 100 checks — they counted the first page rather than the org. Backed by a new cached stats API (GitHub issue #172). (#172, #175)
  • dashboard (active incidents): the same class of bug on the incident counter — the "Active incidents" KPI capped at the requested page size (5) because the incidents list endpoint returned no total. The endpoint now returns pagination.total, computed from the same filter as the list so the count can't drift from it, and the tile reads the total. (#175)
  • custom domains (TLS storage, Postgres): prefix-based lookups in the Postgres TLS store (TLSStorageList, and the prefix branch of TLSStorageDelete) used a half-open key range (key >= 'certificates/' AND key < 'certificates0') that only matches under the C collation — every common non-C collation (including glibc en_US.utf8, the default on the official postgres image and most distro installs) primary-ignores /, so the range silently matched nothing. On an affected database this meant customDomainCertStatus stayed "none" forever even after a successful ACME issuance, and a prefix delete could leave certificate private keys behind undeleted. Fixed by switching to a collation-independent LIKE 'prefix%' ESCAPE '\' match (the SQLite store is unaffected and intentionally keeps its range comparison — see its code comment). (#175)
  • integrations (Google Chat / Mattermost): the senders read webhookUrl while the dashboard saved webhook_url, so a webhook configured through the UI never fired. (#175)
  • checks list (filtering): empty check groups are hidden while a search or filter is active, instead of leaving a wall of empty group headers behind (GitHub issue #171). (#171, #175)
  • results (compaction): aggregation was not transactional — a window of raw rows could survive compaction, leaving duplicated data behind. (#170)
  • results (storage): tier-1 storage trim — dead columns and duplicated payloads dropped from the results table. (#170)
  • Status pages: a regression guard for the recurring removeChild crash, which had no E2E coverage until now. (#175)

0.6.2 — 2026-07-21​

(diff)

Bug Fixes​

  • checks (DNS): the check form had no way to assert on what a DNS lookup actually resolved to — it could only confirm the lookup succeeded. A record, AAAA record, and every other supported record type (CNAME, MX, NS, TXT) now expose an "Expected" field: A/AAAA use a chip input (validated as IPv4/IPv6 respectively, invalid entries block save) since a lookup can return multiple IPs, while the others use a one-value-per-line textarea (TXT values may contain spaces, which a chip input would incorrectly split on) matched exactly against the resolved records. The check fails unless every listed value is present; leaving the field empty keeps today's behavior of only requiring a successful resolution. Switching record type always writes only the matching config key, never both (the backend rejects a config carrying both expected_ips and expected_values). (#161)

0.6.1 — 2026-07-21​

(diff)

Bug Fixes​

  • checks (HTTP): the expected-status field only ever accepted a single numeric code, even though the backend has long supported a list of exact codes and NXX wildcards (4XX, 5XX, …) — the UI just never exposed it. The HTTP check form now uses a chip input: type or paste 200 201 4XX (space/comma/semicolon/Enter-separated) to get one removable chip per code, invalid patterns render as a destructive chip and block save, and a plain 200-only check keeps saving with neither status key (today's implicit-default behavior, unchanged). Existing checks using the deprecated single expectedStatus field keep working and migrate to the list on next save. The chip input itself is a new reusable TokenChipsInput shared with (and replacing the bespoke implementation behind) the email-integration recipients field, and is documented in the dashboard's design reference. (#158)

0.6.0 — 2026-07-21​

(diff)

Features​

  • checks (multi-region): selecting several regions no longer divides the check frequency between them — the configured period now applies per region. A 1-minute check on 3 regions runs every minute in each region (previously every 3 minutes per region), with executions staggered evenly across the period by default (e.g. +0s / +20s / +40s). A new optional regionSpread override — a first-class field on the check, exposed as a "Region spread" control on the check form (shown with 2+ regions, all locales) — forces a custom inter-region offset (e.g. 1s for near-simultaneous sampling to compare cross-region latency); validated 0 ≤ spread < period, empty reverts to automatic spreading. Existing multi-region checks are migrated by an idempotent startup reconcile (migration 007_v0_6_0, checks.region_spread). (behavior change: a multi-region check now actually executes regions × more often and its checks-per-minute consumption is counted as regions × 60s / period — the check form and the org Usage page both say so.) (#156)
  • check groups: groups are now manageable end-to-end from the dashboard — a group edit page with an escalation policy picker (and a policy indicator on the groups list), direct edit/delete row actions replacing the old overflow menu, delete from the edit page, and a breadcrumb back to the checks index. (#156)
  • checks (list API): opt-in sort=group on GET /checks orders results by check group with a composite keyset cursor, so the grouped checks index paginates correctly instead of slicing groups apart across pages; default ordering is unchanged and unknown sort values are rejected. (#156)
  • private locations (deported agents): a new maxDeportedAgents entitlement caps how many agents an org can enroll — enforced at both enrollment-token mint and agent enrollment — with agent usage shown on the org Usage page and a SaaS plan-gate ladder documented. (#156)
  • server (internal): the HTTP router migrated from the archived uptrace/bunrouter to go-chi/chi behind an in-repo httpx adapter that keeps error-returning handlers and the middleware-group ergonomics; route-matching precedence is covered by table-driven parity tests. (#156)

Bug Fixes​

  • checks (sub-minute): a sub-minute check could sit idle past its tick when every worker was parked on the long-poll — the fetcher now wakes on a next-eligible-job hint instead of waiting out the full poll interval, on both in-process workers and deported agents. (#156)
  • dash0 (check groups): the checks index no longer flashes a false "no checks" empty state for a group whose checks live on a later page. (#156)
  • dash0 (dialogs): long unbreakable strings (e.g. enrollment tokens) no longer blow the dialog grid out past the viewport; an E2E guard pins the containment. (#156)

0.5.0 — 2026-07-19​

(diff)

Features​

  • private locations (deported agents): monitor infrastructure that has no inbound access at all, by running a SolidPing agent inside your own network. Enroll it with a single-use spe_ token (stored hash-only) and it generates its own keys and connects outbound-only over a WebSocket (GET /api/v1/agent/ws), authenticating reconnects with an Ed25519 signature over method|path|timestamp|nonce (±5min skew, nonce replay cache) — no usable agent credential is ever stored server-side, only public keys. Checks are pinned to a customer-defined private region, namespaced @<org>/<region> and matched on exact equality only, so a cloud worker can never prefix-match its way into a private job. A check that targets private regions only has its credentials sealed to that region's agents (filippo.io/age X25519 multi-recipient, in a v2 envelope alongside the existing v1 symmetric one), so the server provably cannot open them. Adds an org Private locations page, region CRUD + enrollment-token mint/list/cancel + agent list/revoke, agent mode via SP_NODE_ROLE=agent (SP_AGENT_SERVER_URL, SP_AGENT_ENROLLMENT_TOKEN, SP_AGENT_KEYS_FILE, SP_AGENT_KEYS, SP_AGENT_NAME), and migration 006_v0_5_0 (agents, agent_enrollment_tokens, checks.config_sealed, check_jobs.config_sealed). (breaking: the unused HTTP edge-worker API — /api/v1/workers/{register,heartbeat,claim-jobs,submit-result} — plaintext spw_ worker tokens, and the workers.token column are removed; in-process workers and heartbeats are unaffected) (#145)
  • private locations (deported agents): registering an agent is now a guided, step-by-step wizard (pick or create the private region, mint the enrollment token, copy-paste-ready docker run / docker compose / Kubernetes snippets with the server URL and token already filled in, then a live "waiting for connection" step that reports the new agent by name) instead of a bare token dropped on the page with no instructions; the enrollment-token list also now keeps a consumed token visible for an hour after use (with who used it), so the wizard's own success check can't lose the token it's watching to a race with the list dropping it. (#145)
  • checks (SSH tunnels): now works for 18 check types (was http/tcp only) — databases (postgres, mysql, mssql, oracle), message brokers/queues (redis, mongodb, rabbitmq, kafka), and more (smtp, imap, pop3, ssl, grpc, websocket, ftp, mqtt) can all probe through a bastion; UDP/ICMP-based types stay untunneled since SSH only forwards TCP. HTTP and TCP checks reach services only available through a bastion by dialing through an existing SSH check's connection — set tunnelCheckUid, or pick the bastion in the tunnel selector on the check form. Hostnames are resolved on the far side of the tunnel, so private DNS names work. Tunnel setup is measured as its own tunnel_setup_ms metric and deliberately excluded from the check's reported latency, and a tunnel failure is classified distinctly from the target being down. The selector is driven by server-declared supportsTunnel capability metadata rather than a hard-coded list. A bastion must have expected_fingerprint set to be usable as a tunnel — an unverified bastion is a silent MITM on everything it carries, so this is refused rather than warned about — tunnels cannot be chained, and deleting a check that others tunnel through is refused with 409. The SSH/SFTP check form now exposes the private-key and host-key-fingerprint fields directly (previously the only way to make a bastion tunnel-eligible was via the API). Tunnels also now work when the dependent check is dispatched to a deported agent: the bastion's sealed credentials ship to the agent verbatim — never re-encrypted, never exposed to the server on this path — and the agent resolves the tunnel itself; the referenced SSH check must be allocated to every private region the dependent runs in, and a cloud-region dependent's bastion may not be sealed-only. (#145)
  • checks (SSH tunnels): the empty-state selector now deep-links straight to a preselected SSH check form instead of dropping you on the plain checks list and losing your place; the disabled-option copy names the missing field ("needs a host key fingerprint") instead of the raw config key; the checks list shows a muted icon with a "via <bastion>" tooltip on tunneled checks. (#145)
  • Incidents: incidents can now carry a discussion — comment from the incident page in the dashboard, or reply in the Slack thread SolidPing posted for the incident and it's ingested back as a comment (reverse thread lookup), with retry-safe dedupe so a flaky Slack API call can't double-post the same comment. (#145)
  • Authentication: OAuth/MCP clients (and the sp CLI) can now revoke their own grant once their work is done, instead of it lingering until TTL — a standards-compliant RFC 7009 POST /api/v1/oauth/revoke endpoint (advertised via revocation_endpoint), a bearer-only DELETE /api/v1/auth/tokens/current for a client that no longer holds its refresh token, GET /api/v1/tokens now marks which grant is "yours" (isCurrent), and sp auth logout revokes server-side before clearing local credentials. (#145)
  • server: unrecognized SP_* environment variables now log a startup warning, so a typo'd or stale env var in a deploy is no longer silently ignored. (#145)
  • Escalation: organization default escalation policy plus an incident assignment picker, alongside a consolidation of the migration set. (#148)

Bug Fixes​

  • checks / integrations (secrets): ⚠️ fixed an active credential leak — with SP_ENCRYPTION_MASTER_KEY unset, GET/LIST responses on checks and integration connections returned secrets (SSH private keys, HTTP basic-auth passwords, connection tokens) in plaintext to any org member, including viewers. Secret/public config separation is now unconditional in every storage mode: a new plaintext envelope replaces the old behavior of merging secrets back into the public config when no master key is configured, pre-existing rows with secrets sitting in the public column are migrated on startup, and API responses redact any type-declared secret field as defense-in-depth regardless of storage mode. If you have run with encryption disabled, rotate any check or integration credentials configured before upgrading. (#145)
  • checks (encryption): with SP_ENCRYPTION_MASTER_KEY set, encrypted checks ran without their secrets. The in-process worker only ever read the public half of the config, so HTTP passwords, SSH private keys and database passwords were silently stripped before execution — the check then ran, and failed or misreported, as if the credential had never been configured. Secrets are now decrypted and merged at the claim boundary, and a job whose envelope cannot be opened is never dispatched credential-less and never silently skipped: it is dropped from the batch and reported as an explicit error result naming the fix, which also releases its lease so the check goes visibly red instead of stalling. (#145)
  • checks (logging): secret config values are no longer written to the per-execution Executing check job log line, which printed the check's config verbatim at INFO level. This also affected deployments with encryption disabled, where secrets live in the public config by design and were already being logged in the clear. Values whose keys the check type declares secret are now replaced with <redacted> (keys are kept, so the line stays debuggable), and a check type that cannot be resolved has every value redacted rather than none. (#145)
  • checks (HTTP): basic-auth credentials are no longer stored in cleartext — username and password are folded into a single encrypted basicAuth config key. Editing an HTTP check no longer silently wipes its stored secret headers. (breaking for API consumers reading config.username / config.password directly: both leave the public config once a check is re-saved. Existing rows keep working until then — lazy migration, no backfill.) (#145)
  • Incidents: an incident could auto-resolve on its first success, ignoring the configured recovery period, because the recovery clock was shared across consecutive incidents and still carried the previous incident's timestamp. It is now scoped to the incident it resolves. (#145)
  • Rate limits: the default per-client HTTP limits were low enough that normal dashboard use produced 429s — on an org with live checks, one checks-page tab refetches every check-group panel on each realtime tick (~200 req/min for 10 groups), eating most of the old 300/min budget, so a reload or a second tab tripped the limit. Defaults are now sized against that measured profile: requests_per_minute 300 → 1800, burst 60 → 360, rate_queue 10 → 60, concurrency_queue 10 → 40 (max_concurrent stays at 20 — a page-load burst of parallel fetches now parks briefly in the deeper waiting room instead of 429ing; deployments that set SP_SERVER_RATE_LIMITING_* explicitly are unaffected). (#145)
  • dash0 (checks): the checks list page issued one request per check group on every realtime tick, which is what drove the rate-limit pressure above in the first place; it's now batched into a single query grouped client-side, and the query retry policy honors a 429 response's Retry-After header instead of a fixed backoff. (#145)
  • private locations (deported agents): a deported agent that lost its connection to the server gave no indication in its own logs and only reconnected lazily, on the next claim/submit — so a silent network drop could leave express dispatch dead until the next poll, and an in-flight request could block for the full 30s timeout even though the connection was already gone. The agent now logs disconnects, proactively reconnects with exponential backoff, pings its own connection for liveness (catching a "half-open" drop within seconds instead of an OS-level TCP timeout), and fails in-flight requests immediately when the connection drops instead of waiting out the full timeout. (#145)
  • dash0 (realtime): the live-updates WebSocket authenticated only via the shared access_token cookie, which another app on the same host can shadow (browsers ignore ports when scoping cookies) — causing the socket to 401 forever and reconnect-loop while the rest of the dashboard kept working normally over REST. It now authenticates the same way REST does, via a bearer token carried as a WebSocket subprotocol (browsers can't set an Authorization header on the handshake), with the cookie kept only as a fallback. (#145)
  • checks (SSH tunnels): with SP_ENCRYPTION_MASTER_KEY unset, every check tunneled through an SSH bastion failed on every execution (cannot decrypt the ssh check credentials... (encryption disabled)), even though the referenced SSH check itself ran fine — fallout from the credential-separation fix above: secrets now always live in a plaintext envelope when no master key is configured, but the SSH tunnel resolver and the Freebox LAN-lookup channel resolver still gated on Enabled() before ever trying to open it. Both now open a plaintext envelope first and only fall back to the key-requiring error for envelopes that actually need a key, matching how the rest of the codebase (job-secret merge, API loaders) already handles this. (#149)

0.4.1 — 2026-07-15​

(diff)

Bug Fixes​

  • realtime: enforce org authorization on all REST /orgs/:org routes and unify it with the WebSocket check, so cross-org navigation auto-switches org instead of leaking access or breaking the live connection (#138)
  • heartbeats: accept an Authorization: Bearer token plus a structured JSON body, rendered as its own result-detail card in dash0 (#138)
  • Billing & entitlements: rename the seat-cap limit maxSsoUsers → maxUsers (provider-neutral), enforced at invitation acceptance (breaking: SaaS billing integrations reading maxSsoUsers must switch to maxUsers) (#138)
  • Authentication: fix a flaky wall-clock assertion in the local-password-vs-LDAP-priority login test (#138)
  • embedded db: change the embedded-PostgreSQL default port 5434 → 5433 (breaking for local setups pinned to the old default port) (#138)

0.4.0 — 2026-07-14​

(diff)

Features​

  • dash0 (checks): drag-to-select X-axis time zoom on the response-time chart — the zoom window lives in the URL (graphFrom/graphTo) and drives a server-side fetch of just that window (picking a finer aggregation tier for narrow spans); the selected point is also URL-persisted (graphSelected), so a shared link reproduces both the zoomed window and the highlighted result with its details; "Reset zoom" button + double-click reset; touch-drag works on mobile
  • admin: new super-admin "Aggregation" server-settings tab to configure the three retention windows (raw→hour in hours, hour→day in days, day→month in months) on the live performance.aggregation_retention_* parameters — server-side write validation (integer >= 1, else VALIDATION_ERROR), client-side floors, and inline notes that changes never re-aggregate or restore already-rolled-up data
  • aggregation: tighter default retention — hourly rows now roll up after 7 days (was 30) and daily rows after 2 months (was 12); raw stays 24h. Values remain configurable via the new Aggregation tab (behavior change: shortens how far back granular history stays queryable for deployments on defaults)
  • realtime: WebSocket authentication moved to the HTTP level, before the upgrade — the handshake authenticates via Authorization: Bearer header or the access_token cookie (header wins) and answers a bad/missing token with a plain HTTP 401 instead of upgrading-then-closing; the in-band {"type":"auth","token":…} message and the SP_REALTIME_AUTH_GRACE grace window are removed (breaking for custom WS clients that authenticated in-band — send the token as a header or cookie instead); org-scope (4403) and disabled (4404) remain post-upgrade close codes, 4401 now means mid-connection token expiry only
  • integrations (email): notification emails can target multiple recipient addresses — chip/tag input with paste support (comma/semicolon/space/newline separators), per-address validation with invalid chips flagged in red and blocking save, deduplication, and mobile-friendly tap targets

Bug Fixes​

  • Incidents: GET /api/v1/orgs/:org/incidents?checkUid=… no longer 500s when given a check slug — the identifier resolves slug-or-uid exactly like /results, and an unknown identifier returns an empty page instead of an error (or an unfiltered list); the check detail page now passes the resolved uid (#127)
  • dash0 (checks): dependency edges pointing at a deleted check no longer render as bogus bare "Hard"/"Soft" badges — orphaned edges are filtered out of the API response, a check's dependency rows are now cleaned up when it is deleted, and the UI falls back to a muted "Unknown check" label for any unresolved ref (#129)

0.3.0 — 2026-07-12​

(diff)

Features​

  • Checks: new RDP protocol check type — pre-auth X.224 negotiation with NLA and certificate-expiry knobs
  • Checks: optional per-check timeout field (1–30s) in the shared check form; server-side 30s cap, execution context = timeout + 1s
  • Checks: configurable global check timeout (default 15s), execution context = timeout + 1s
  • Checks: export format v2 — defaults block, human-readable durations, and deterministic ordering (group empty-last, then slug)
  • Dashboard: badges page check picker swapped the capped Select for a live-search CheckPicker
  • Dashboard: AI assistants (MCP) page moved under Account with a command-palette entry (findable by "MCP" or "AI"); legacy paths redirect
  • Dashboard: empty-state onboarding hero now offers the MCP / AI path for creating checks (mobile-usable)
  • status: rich link previews — per-page Open Graph metadata injected into served status pages, plus a branded 1200×630 default image
  • admin: region slugs resolve to friendly "{emoji} {name}" labels (e.g. "EU1 (default)") on admin surfaces, seeded from SP_REGIONS
  • Integrations: the "Default for new checks" toggle now starts enabled when creating an integration
  • Localization: idiomatic FR/ES/DE auth-page headline translations replacing literal calques
  • escalation/on-call: escalation policies and on-call schedules drop their unused slug — both are addressed by uid only now (API routes, dashboard links); old slug-form URLs return 404 (breaking: the slug field is gone from create/update/response payloads)
  • CLI: the sp CLI now covers the full API surface — ~20 new command groups (channels + per-check bindings, status-pages/status-updates, maintenance-windows, check-groups/severities/labels/regions/check-types, on-call schedules + escalation-policies, orgs/settings/invitations/membership-requests, entitlements/files/email-suppressions, jobs admin & stats + check-jobs, server & system ops, notifications + routes/contacts, and auth self-service) plus the previously-missing operations on existing groups (incident ack/snooze/resolve, check validate/clone, dependency update/graph, results get, availability, discovery, heartbeat send); the OpenAPI spec and generated client were backfilled to match
  • Dashboard: the check create/edit form was restructured with progressive disclosure — an always-visible Identity/Scheduling/Notifications core plus collapsed-by-default sections (Authentication, Organization, Dependencies, Incident tracking, Flapping, Advanced) with header value-summaries and auto-expand on validation error; adds a reusable CollapsibleSection primitive and refactors the form behind a per-check-type module registry (one serializer feeds both the preview and the submit), and extends/documents the /checks/new query-param prefill

Bug Fixes​

  • realtime: fix an EventNotifier listener leak — GetJobWait leaked one channel per processed job and eventually silenced the realtime WebSocket with zero logs; adds Unlisten, a non-stacking Postgres keepalive ping, and a listener-growth warning
  • Dashboard: unify the useCheck query key so a live check update fetches the check once, not twice (single canonical cache entry)
  • Dashboard: incidents and checks list pages now subscribe to live updates (they never registered a live scope, so they only updated on reload)
  • MCP: method-aware /api/v1/mcp (GET redirect/405, DELETE session termination) and JSON 404 for unmatched /api/ paths — a browser GET no longer falls through to the web UI
  • Rate limits: key authenticated buckets by bearer token with a per-IP cap, fix off-by-one XFF client-IP extraction, and coalesce live-hint cache invalidations to damp refetch storms
  • Authentication: SSO callbacks now set the access_token cookie so MCP OAuth consent skips the login-page bounce
  • checks (DNS): repair the DNS check form — bind Domain to the host key, add DNS-server and record-type fields, and load samples correctly
  • checks (DNSBL): treat Spamhaus 127.255.255.x replies as error codes rather than genuine listings (fixes false-positive blocklist hits)
  • dash0 (DNSBL): render the DNSBL result card with human-readable Spamhaus status-code labels
  • status: the status-page Atom feed no longer 500s on Postgres — bind status_page_uid through bun ($1 → ?)
  • aggregation: fix a poison-pill loop where marker-only result buckets re-aggregated forever, duplicating hour rollup rows and scheduler jobs unbounded — lifecycle markers are excluded from work discovery, aggregation only reschedules immediately when it made real progress, aggregated writes are idempotent upserts, and a NULL-region-proof unique index closes the duplicate hole (the v0.5.0 migration dedupes existing rows); rollup retention now comes from global performance.* parameters
  • aggregation: fix an FK-orphan poison pill where a results row whose check was hard-deleted failed the rollup insert and permanently halted the org's aggregation — orphaned buckets are skipped in discovery, the job always reschedules its follow-up after a stage error, SQLite enforces foreign keys on every connection, and the v0.5.0 migration purges any existing orphans
  • jobs: the jobs table no longer grows unbounded — a daily jobs_cleanup job soft-deletes finished jobs after 48h and hard-deletes them 24h later (retry-chains drained tail-first), with thresholds configurable via performance.* parameters

0.2.3 — 2026-07-10​

(diff)

Bug Fixes​

  • Slack: send conversations.list/users.list parameters form-encoded so Slack honors types/limit/cursor. Sending them in a JSON body made Slack fall back to defaults (100 items, first page only, public channels only), so the destination picker silently dropped private channels (e.g. #solidping-dev) and every channel past the first 100 (#119)
  • Slack: de-duplicate destination channels and users by ID and stop pagination on a non-advancing/repeating cursor, fixing the picker listing each channel dozens of times (#119)
  • Dashboard: preserve the deep returnTo destination through third-party (OAuth/SSO) login instead of always landing on the organization root (#119)
  • realtimews: align the WebSocket handshake organization check with the REST middleware, denying cross-org database super-admin access (#119)

0.2.2 — 2026-07-09​

(diff)

Features​

  • Authentication: enterprise SSO — generic OAuth2/OIDC provider (discovery + ID-token validation, email_verified gating against account-takeover), SAML 2.0 SP (crewjam/saml, full assertion validation, metadata endpoint), and LDAP/AD bind auth (search-then-bind, RFC-4515 escaping, super-admin lockout guard), all sharing maxSsoUsers entitlement counting and UserProvider linking (#118)
  • Authentication: per-org configurable max session duration — auth.session_max_duration system parameter with a per-org override on the org settings API (#118)
  • Dashboard: multi-region response-time chart shows red failure segments and always-visible failure dots even on dense views (#118)
  • Dashboard: colored status badges in "Checks at a glance" and the pinned check-result box (#118)
  • Incidents: restyled ack magic-link page with a countdown redirect to the incident (#118)

Bug Fixes​

  • Authentication: sessions no longer die ~1h after login — repaired the token-refresh chain and an OAuth handoff that dropped the refresh token (#118)
  • Authentication: creating an org from /no-org now mints an org-scoped session token instead of leaving every API call 403'ing (#118)
  • Authentication: auth.Service reads overlaid AuthConfig fields (registration-disabled, session cap) live instead of from a frozen snapshot (#118)
  • Authentication: a zero-org session is kept alive — /auth/me returns no-org info instead of 401-logging-out the user (#118)
  • Dashboard: deep links survive login — every post-login path resolves through a shared returnTo resolver with a tightened same-origin open-redirect guard (#118)
  • Dashboard: uptime-bar badge shows the full 7d/30d/90d history instead of only 1/2/3 days (#118)
  • Dashboard: validateSearch boolean query-params (showSuppressed, graphFull) no longer silently no-op from string-vs-bool coercion (#118)
  • Checks: created lifecycle results are no longer swallowed by aggregation (#118)
  • Slack: channel/user pickers paginate (cursor) so large workspaces are fully searchable (#118)
  • devloop: hard-cap the rotating log so a newline-less/hot-looping child can't grow logs/backend.log without bound (#118)

0.2.1 — 2026-07-08​

(diff)

Features​

  • Dashboard: one-click MCP connector setup — AI assistants page with a URL-only OAuth flow, per-client icons, shared copy/collapsible code primitives (#111)
  • Dashboard: Recent activity links directly to the originating incident/check and shows the channel name; incident lifecycle events enriched with check UID/name (#111)
  • Billing & entitlements: SaaS Free-plan defaults now match the billing Free plan (10 checks, 6/min, no SSO), with a backend-driven "Free"/"Team"/"Self-hosted" plan display identity (#111)
  • devloop: make dev/make dev-test supervise dash0 and status0 with size-rotated per-process logs instead of unbounded raw output (#111)

Bug Fixes​

  • Checks: list endpoint no longer overfetches O(retention) raw rows per check for lastResult — one row per check via DISTINCT ON (#111)
  • e2e: dashboard E2E suite flaked on per-test login contention; authenticate once per Playwright worker instead of once per test (#111)
  • db: embedded-Postgres orphan sweep + watchdog reclaims leaked dev/test instances left behind by crashed processes (#111)
  • realtime: check-detail live subscription used the URL slug instead of the canonical uid, silently breaking live updates on slug-based check URLs (#111)
  • e2e: hardcoded localhost:4000 in several spec files ignored E2E_BASE_URL, breaking test runs against non-default servers (#111)
  • checkworker: check results fell back to a null region for default-region checks instead of the executing worker's own region (#111)
  • Notifications: a malformed notification UID errored on the Postgres uuid column cast instead of rendering the friendly not-found state (#111)
  • Dashboard: live-socket CONNECTING crash + route-level error boundaries (#108)
  • Slack multi-org, region filtering, mail-checker hang fixes, live-status dot (#100)

0.2.0 — 2026-07-04​

(diff)

Features​

  • realtime: replace SSE with a WebSocket transport with per-entity subscriptions — v1 shipped org-scoped SSE hint events where a connection got every hint for its org; v2 is a clean swap (not yet released, so no deprecation window) to a WebSocket transport where a connection receives nothing until it subscribes to a check (by uid) or a checks/incidents/events/jobs collection scope, with a pre-authenticated-or-in-band-message handshake, storm collapse above 64 distinct check uids in one flush window, scope-keyed dirty-set dispatch, subscription caps, and a dash0 refcounted subscription registry with per-scope poll-stretch gating (#89)
  • scheduler: check-execution fairness — WFQ-style claim ordering (effective_scheduled_at), per-plan tier credit, cost EWMA (#89)
  • scheduler: fast/slow check lanes — lane smallint on check_jobs, hysteresis classifier, partial claim indexes, reservation-style two-SELECT claim with a reserved fast floor (fast_lane_reserved, borrowing allowed), busySlow accounting + lane metrics (#89)
  • scheduler: heavy-check demand control — server-side MinPeriod validation on create/PATCH (browser 60s, js 30s, global 10s floor; sleep/internal exempt), read-only scheduling block (cost/delay EWMA, duty cycle) on check detail with a ≥50% duty-cycle warning, cost-aware execution timeout on by default (#89)
  • scheduler: sleep checker + browser-to-sleep harness for scheduler load testing; cost-distribution management endpoint (#89)
  • discovery: discovery foundation, container discovery, Kubernetes discovery, and a Kubernetes checker (#89)
  • maintenance-windows: dash0 UI, status0 badge, recurrence (backend + frontend), breadcrumbs/timezone polish (#89)
  • Availability: server-side availability-statistics API — real per-period measurements replacing the client-side estimate; status pages exclude lifecycle results; uptime bar data-source parity; 24h hourly history period; dashboard KPI tile removed in favor of the server numbers (#89)
  • Checks: NTP checker (#89)
  • Checks: grey status dot for disabled checks (#89)
  • Authentication: configurable password hashing, plus a Server Settings UI and rehash-on-change (#89)
  • Authentication: Microsoft OAuth tenant ID setting (#89)
  • memory runtime guardrails + consumption analysis (#89)
  • Incidents: adaptive recovery redesigned as capped flapping backoff, with confirmation/recovery probe-count estimates (#89)
  • db: consolidate migrations into a v0.2.0 baseline delta; collapse OAuth storage from three dedicated tables to reusing state_entries/user_tokens (#89)

Bug Fixes​

  • realtime: stale OAuth-consent access_token cookie fail-fast rejected the WS handshake instead of falling through to fresh in-band auth; plain result writes didn't invalidate the check/checks cache, drifting "last checked" stale (#89)
  • Slack: Socket Mode parameter fix (#89)
  • Incidents: grouped-incident duplicate-resolved-notification fix; fast-relapse incident reopen fix (#89)

Miscellaneous Chores​

  • retry release-as trailer for v0.2.0 (#98)

0.1.0 — 2026-06-22​

(diff)

Features​

  • add check type registry, sample configs, notification senders, and observability integrations
  • status: first-class Warning (live) + Degraded (aggregated rollup) status values across all three status enums; Warning counts as up for availability and is incident-neutral (never opens/resolves an incident); aggregation promotes a non-failing window containing a Warning to Degraded (#85)
  • Checks: SSL graduated expiry — two-tier warningDays/criticalDays, expiry decided by the whole-chain minimum, chain-report output + UI; warning tier is amber (no page), critical is Down (pages) (#85)
  • Checks: Docker restart-loop detection — opt-in heuristic; a running-but-crash-looping container reports Warning (doesn't page), not-running/unhealthy still reports Down (#85)
  • MCP: OAuth 2.1 — turns SolidPing into an embedded OAuth 2.1 authorization server for the MCP endpoint: discovery, RFC 8707 audience binding, authorize/consent/token with mandatory PKCE S256, single-use codes, rotating refresh, RFC 7591 dynamic client registration with a loopback exception; PAT-bearer back-compat preserved (#85)
  • config-as-code — POST /orgs/:org/checks/apply (+ sp apply) reconciling the existing export format: managed-label scope, dry-run/diff, delete-by-absence gated by prune + managed-label + a deletion cap, ${env:}/${param:} secret references resolved server-side; sp checks export/import added (#85)
  • Dashboard: check-detail header action toolbar drops onto its own row so a long check name no longer squeezes it (#85)
  • Dashboard: destructive button gets red-tinted elevation tokens so Delete matches the Save button's hover lift (#85)
  • refresh the brand mark's favicon set — the 4 favicon/touch PNGs re-rendered from the new logo.svg (#85)
  • jobs: admin Jobs observability page with status and type filters (#78)
  • jobs: export background-job metrics to Prometheus (#78)
  • jobs: reap stuck running jobs via a stuck-job reaper (#78)
  • jobs: add Jobs section breadcrumbs (#78)
  • Notifications: delivery detail page capturing delivery artifacts (status, bodies, timings) (#78)
  • Notifications: flat notification route with source breadcrumb and integration notification history (#78)
  • Notifications: show notification durations in days (#78)
  • Integrations: rename Channels to Integrations across the dashboard (#78)
  • Integrations: stamp a device label on webpush integration subscriptions (#78)
  • Integrations: integration edit page save / send-test button states (#78)
  • Slack: link the monitor name and status tags in Slack notifications to the dashboard (#78)
  • Dashboard: dashboard "checks at a glance" overview (#78)
  • Dashboard: redesign the checks-list label filter as a faceted picker (#78)
  • Dashboard: standardize every list and section page on the canonical PageHeader component (#78)
  • Dashboard: check-detail header keeps its actions inline, shrinking to icon-only on mobile instead of an overflow menu (#78)
  • Status badges: add a Badges button to the check-detail header linking to the badge builder (#78)
  • Status badges: badge contextual labels; badges link back to their originating check (#78)
  • Dashboard: uptime bar colours each segment by a segment-width percentage threshold (#78)
  • Incidents: restyle incident-detail action buttons (icons and layout) (#78)
  • discovery: discovery detail back arrow and desktop refresh control (#78)
  • Status pages: status-pages detail back-arrow placement in the action cluster; status-updates "new" button placement (#78)
  • Authentication: remember the last authentication method and propose it first on login (#78)
  • Authentication: login page polish — inline forgot-password link, tighter dividers, passkey as a link (#78)
  • Billing & entitlements: SaaS entitlements with a per-org usage page (maxChecks, maxChecksPerMinute, SSO users) (#78)
  • refresh the brand mark — high-fidelity logo and favicon matching solidping.io (#78)
  • checkworker: reduce per-result DB round-trips in the check-worker hot path (#78)
  • discovery: raise MaxScanChunks from 256 to 4096 and add a large-range CIDR warning in the UI (#73)
  • status-updates: move the status-update badge to the right side of the status update card (#73)
  • Web push: foundation — VAPID keypair lifecycle, app_settings KV table, service worker, subscription hook, and enable button (#73)
  • Web push: dispatch web push to escalation policy users and org-channel subscribers, plus a test-send endpoint (#73)
  • Status badges: buffer numeric width/minWidth inputs in local state, committing to the URL on blur, so intermediate typing values aren't rejected by route validation (#73)
  • custom user-agent, refresh checks, vite plugin switch, CI updates (#1)
  • documentation site served at /docs (co-located in web/docs) (#88)
  • email check frontend (spec 03) (#30)
  • email inbox foundation via JMAP (spec 01) (#24)
  • email passive checks (spec 02) (#27)
  • Billing & entitlements: trim to MaxSSOUsers + MaxChecksPerMinute + status page history fixes (#50)
  • Billing & entitlements: trim to MaxSSOUsers + MaxChecksPerMinute, enforce both (#49)
  • group incident correlation (spec 04 backend v1) (#31)
  • observability: instrument hot path + add bench harness (#60)
  • On-call: searchable timezone dropdown + sidebar logo polish (#46)
  • per-IP HTTP rate limiting and concurrency limiting (#59)
  • soften HTTP rate limits with bounded queues + request timeout (#63)
  • SolidPing — distributed uptime monitoring platform
  • Status pages: drag-and-drop resource reordering + dash conventions (#41)

Bug Fixes​

  • Web push: fix the empty uptime-bar / response-time graph bucket query to read all tiers (raw + hour + day) and accumulate correctly (#73)
  • Integrations: webhook URL key mismatch — migration renames webhook_url → url in integration_connections.settings and backfills existing data (#73)
  • Authentication: invite-already-signed-in — gate the "Creating account for…" note on auth state (#73)
  • Integrations: send email-integration notifications to the configured recipients (#78)
  • Integrations: render the webhook URL on the integration edit page (#78)
  • Authentication: passkey login shows a precise error on RP-ID / domain mismatch (#78)
  • Authentication: forgot-password link is no longer bounced back to login by the org auth guard (#78)
  • Status badges: correct the badge uptime-bar last-segment width (#78)
  • status lifecycle improvements and created/running result handling (#5)
  • Status pages: white page header and de-duplicated footer (#48)