Changelog
0.44.3 — 2026-11-07
(diff)
Bug Fixes
- Notifications: degraded incidents are now announced as degraded, not as outages (#797)
0.44.2 — 2026-11-07
(diff)
Bug Fixes
- agent: system agents no longer poll the worker-task routes (#791)
- API: cap and dedup the checkUid list filter of results and incidents (#789)
- egress log level, sidebar deep link, plus specs (#793)
0.44.1 — 2026-11-07
(diff)
Bug Fixes
- Security: normalize trailing-dot custom status domains (#778)
- Dashboard: keep valid sessions when a token refresh fails transiently (#782)
- Slack: resolve check and incident link unfurls (#779)
- Documentation: plain markdown for the API reference .md pages (#784)
0.44.0 — 2026-11-05
(diff)
Features
- close status page entries of deleted checks (#764)
Bug Fixes
- org identity on reload, canceled-request error reports, status page entries (#763)
- Security: cap bulk check UID list before dedup (#760)
- test: loosen the timing bound of TestUniqueCheckUIDsIsLinear (#767)
0.43.1 — 2026-11-05
(diff)
Security
- Security: harden the JS check sandbox (memory budget gaps and residuals, regexp time limit and call time, process isolation, parent decode budget, RegExp symbol methods budget), require re-authentication for Discord linking, cap invitations, validate crawl link schemes, restrict the subscriber list by role, invalidate reset links on password change and make TOTP codes single-use (#735, #742, #749)
Bug Fixes
- ai: send DeepSeek's reasoning_content back on assistant turns (#739)
- Dashboard: email-scanner filter, non-admin crash, audit incident target (#755)
- Reports: point "Open dashboard" at the organization's dashboard (#744)
- Documentation: tour video under 500 KB (#752)
Dependencies
- update Go and frontend dependencies: goja, regexp2 v2.12.0, oapi-codegen v2.8.2, modernc.org/sqlite v1.61.1, @hookform/resolvers v7 and other non-major bumps (#736, #756, #746, #747, #754, #753, #730, #750, #748, #751, #758, #737, #733, #732)
0.43.0 — 2026-10-31
(diff)
Features
- auto-create a personal org for new users, resume OAuth after org creation (#716)
Performance Improvements
- Dashboard: load only the active language's translations (#729)
Security
- Authentication: limit sign-ups per client IP (unconfirmed per day, once a minute) (#727)
- JS check memory budget, re-authentication for new sign-in factors (#728)
- bug-report budget for non-members, retention sparing crawl reports (#715)
Dependencies
- update Go dependencies: goja, prometheus/common v0.73.1, slack-go/slack v0.33.0 and other non-major bumps (#720, #722, #724, #725)
0.42.1 — 2026-10-30
(diff)
Bug Fixes
- checkicmp: socket-open failure is an infrastructure error (#712)
- Dashboard: explain chart Full range switch and status page visibility hint (#702)
- ICMP capability for workers, escalation targets on events, login bug report and degraded incident fixes (#701)
- Security: device consent phishing guard and 2FA disable throttle (#693)
- Security: instance-wide budget and retention for anonymous bug-report screenshots (#708)
- Security: PAT org binding, subscribe throttle, invite lookup, SNMP secret (#699)
- Security: scope on-call override deletion to its schedule and org (#714)
0.42.0 — 2026-10-26
(diff)
Features
- ai: hold auto-applied repairs to the previous version's hosts (+ scan specs) (#663)
- API: cursors on the events and incidents APIs (#640)
- Authentication: ask the default admin for a real email on first login (#673)
- Checks: offer to resolve open incidents when a check is disabled (#648)
- Dashboard: locate e2e and showcase elements by data-testid, with lint rules (#672)
- MCP: bound tool string inputs and disclose side effects (#644)
- Notifications: ntfy notifications link to the incident (#639)
Bug Fixes
- ai: hold AI loops to the check's hosts after a real-secrets run (#646)
- ai: hold every scheduled
repair: autorun to its granted hosts (#683) - ai: hold the repair: auto host grant when the script or env holds a reference (#678)
- Authentication: redirect provider logins with an unknown org to the login page instead of raw JSON (#676)
- custom-domain status assets, CSS sample, incident header, check scroll (#689)
- dashboard down count, mobile event log, browser retry, async report test send (#680)
- jobs: retry a due job promptly when its claim rolls back (#674)
- log: drop error=<nil> from successful HTTP request logs (#664)
0.41.0 — 2026-10-22
(diff)
Features
- Email: emails are calmer and say why a check is down (#623)
- private locations: crawl and network discovery now run from a private location (an org agent). The agent opts in to discovery with
SP_AGENT_DISCOVERY_ENABLEDandSP_AGENT_DISCOVERY_CIDRS, scans of a private location need an admin of the org, and a new entitlementdiscovery_agent_addresses_per_daycaps the addresses scanned per day (65 536 on SaaS, unlimited when self-hosted). An org runs one scan at a time. A crawl in flight during the upgrade restarts from scratch, and a crawl that uses${param:}ends in an error on a private location. (#633)
Bug Fixes
- aichecks: when the AI writes or repairs a check, a script that holds real secrets can only reach the hosts it is allowed to, checked when it connects. Before, only the text of the script was checked, so a URL built at run time could send a secret to another host. A script that uses
browser.*orsolidping.*while it holds secrets is refused. (#631) - Dashboard: drag-to-zoom on the Timing chart (#617)
- Email: the usage-cap email links to the platform upgrade page, not billing (#632)
- Status pages: a grouped check's entry closes when it recovers (#615)
- checkdns: the DNS checker test no longer depends on a real resolver (#630)
0.40.1 — 2026-10-19
(diff)
Bug Fixes
- checkworker: checks with a long period are no longer starved by the rate limiter and run on schedule again (#609)
- Dashboard: the HTTP timing chart now has a legend, a dark tooltip in dark mode and a single Response time | Timing switch, and every chart toggle is one segmented control with the selected option in blue (#611)
- Notifications: an AI agent (A2A) whose card or reply is larger than 1 MiB is now refused instead of being read into memory (#608)
0.40.0 — 2026-10-18
(diff)
Features
- Dashboard: the dashboard gets tonal gradients, a middle ground between the flat and the shiny look (#603)
- Documentation: the docs site is redesigned to match www.solidping.io (#599)
Bug Fixes
- Dashboard: the invite button now sits in the organization header (#601)
- Dashboard: fix a flaky checklist e2e test: it now waits for the dismissal request to finish before reloading (#604)
0.39.0 — 2026-10-18
(diff)
Features
- A2A notification channel so AI agents can receive alerts (#596)
- Dashboard: calmer, flatter and phone-friendly dashboard UI (#582)
- vendor status check, SMS/voice quotas, upgrade-path CI and flaky test fixes (#591)
Bug Fixes
- Checks: build the postgres check connection from an explicit pq.Config (#584)
- showcase: film a single region, as a plain docker run shows (#595)
0.38.6 — 2026-10-16
(diff)
⚠ BREAKING CHANGES
This release fixes security issues, and some fixes change behaviour. Check these before you upgrade (#572) (d67afc2):
- Checks:
${env:NAME}references stop resolving untilNAMEis listed inSP_CHECK_ENV_ALLOWLIST, on the server and on each private agent. The list is empty by default. - Slack: An HTTP-mode Slack install needs
SP_SLACK_SIGNING_SECRET. Socket Mode does not. - Authentication: A multi-tenant Microsoft app registration must add the
emailandxms_edovoptional ID token claims. - kubernetes: Connections that use
exec,auth-provideror file references stop working.inClusterneedsSP_K8S_ALLOW_IN_CLUSTER=true. - mysql: The username, password and database name can no longer contain
@ / ( ) ? & :. Use a reference instead. - Authentication: An existing account must sign in before accepting an invitation. MCP OAuth tokens and scoped PATs get 401 on the REST API.
- discovery: Under an enforcing egress policy (SaaS by default),
landiscovery is refused, and so iscontainerdiscovery on SaaS shared nodes.
Features
- deploy: SolidPing can be deployed on Railway. A new installation page covers the dashboard, the CLI and the switch to PostgreSQL.
deploy/railway/has the same setup as Railway Infrastructure as Code: one service, a volume at/dataand a health check. (#576) - release: Builds for linux/riscv64. The server and
spbinaries ship assolidping-linux-riscv64.gzandsp-linux-riscv64.gz, and both Docker images are built for amd64, arm64 and riscv64. (#573)
Bug Fixes
- Authentication: Sign-in tokens can no longer be used as access tokens. Each token is signed with a key for its purpose, so the passkey login session and the 2FA temporary token are refused as access tokens. (#572)
- Authentication: Accepting an invitation for an existing account requires being signed in as that account. The API answers 401
INVITE_LOGIN_REQUIREDor 403INVITE_EMAIL_MISMATCH. (#572) - Authentication: Microsoft sign-in no longer links or creates an account from an unverified email. On a multi-tenant app, the email is trusted only when the ID token carries
xms_edov. (#572) - Authentication: The TOTP code is asked on every SSO and OAuth sign-in. The 2FA, password reset and change password attempt counters are now atomic, and a revoked or expired PAT leaves the cache on every replica. MCP OAuth tokens and scoped PATs are refused on the REST API, the realtime WebSocket and the OAuth consent screen. (#572)
- Checks: Checks and connections can no longer read server secrets or files.
${env:}only resolves names listed inSP_CHECK_ENV_ALLOWLIST, both when a check is saved and when it runs. Kubernetes connections refuseexecandauth-providerkubeconfigs and file references. The MySQL check setsAllowAllFiles=falseand dials through the egress guard. (#572) - egress: More outbound requests go through the egress guard. This covers status page subscriber and publication webhooks, JS
dockersub-checks (which also obey the SaaS docker gate), and LAN, container and Kubernetes discovery. Refused discovery types are hidden. Subscriber webhooks followSP_EGRESS_ALLOW_PRIVATE, so self-hosted instances accept private endpoints. (#572) - Status pages: A status page component can only be changed through its own section. Before, another org's components could be updated or deleted. (#572)
- Slack: Slack endpoints refuse every request when no signing secret is set.
response_urlmust be onhooks.slack.com, and request bodies are capped at 1 MiB. (#572) - Incidents: No degraded alert right after an outage recovers. The 60-probe window still held the outage's own failures, so a
[DEGRADED]email followed the recovery a minute later. The window now starts when the last incident resolved. The email also says "80.0% available" instead of a bare percentage that read like a failure rate. (#577) - SMS: OVH SMS delivery receipts are accepted. Unless
SP_SMS_OVH_DLR_TOKENwas set, the callback URL registered with OVH and the token the server checked never matched, so every receipt got a 404. The URL also pointed athttp://localhost:4000unless the base URL was in the YAML file. (#572) - Configuration:
SP_SERVER_CHECK_WORKER_NBandSP_SERVER_JOB_WORKER_NBtake effect. They were ignored. (#572)
0.38.5 — 2026-10-14
(diff)
Bug Fixes
- Database: Servers upgraded from 0.38.1, 0.38.2 or 0.38.3 start again. 0.38.4 changed a database migration that had already shipped, so the server refused to start on those databases with "migration 027_v0_39_0 migration content changed after it was applied". 0.38.5 starts on every database: a 0.38.1 to 0.38.3 database gets the incident reminder and "Run now" columns, and one that already ran 0.38.4 is left as is. If you are stuck on a crashing 0.38.4, upgrade to 0.38.5. There is nothing else to do. (#558)
0.38.4 — 2026-10-14
(diff)
Features
- HTTP checks: HTTP results split the response time into DNS, connect, TLS, server and download time. The result page shows a Timing card, and the check page has a Timing chart next to the response time. Each probe now opens a new connection, so a check that runs more often than every 90 s now counts connect and TLS time in its response time. The new metrics are
dns_time_ms,connect_time_ms,tls_time_ms,ttfb_time_ms,download_time_msandredirect_time_ms. - HTTP checks: HTTP checks can test response headers with equals, contains, absent and other operators. The new
headerAssertionstakes the same operators as body assertions, plusexistsandnot_exists. Rules like "X-Powered-Bymust not be sent" or "Cache-Controlmust not containno-store" are now possible. The check form has a new "Header assertions" section, andheaders_patternkeeps working. - HTTP checks: An HTTP check can test where its redirects end up. Set
expectedFinalUrl, and the check goes down when the final URL is different, for example a login or parking page. The comparison ignores the case of the scheme and host, default ports and the fragment. The failure output includesfinal_url. - Dashboard: An HTTP check can be copied as a cURL command. "Copy as cURL" is in the check page menu and on the incident page. The command has the method, headers, body, redirects, TLS verification, HTTP version and timeout. Secrets are never included: basic auth becomes
$SOLIDPING_BASIC_AUTH, and a comment line says when secret headers were left out. - Checks: Checks can send a new value on every run to get past a CDN cache.
${var:timestamp},${var:iso8601},${var:uuid}and${var:random}work anywhere${env:...}does, for example?cb=${var:timestamp}. Within one run, each name keeps the same value. The URL stored in a result now keeps${param:...}references instead of the secret values. - ICMP checks: An ICMP check can go down on packet loss or a slow round trip. Set
max_packet_loss_percentormax_avg_rtt_ms. Before, one reply out of five kept the check up. The incident gives the reason, for examplepacket loss 40% > 20%. - Notifications: Incident notifications say which regions see the failure. Alerts include "Failing from: Europe West, US East (2 of 3 regions)", and resolved alerts include "Failed from: ...". The webhook payload has a new
incident.regionswithfailingRegions(slugs),regionCountandfailQuorum. It is recorded when the incident opens and updated when it escalates. - Incidents: Open incidents that nobody has acknowledged can send reminders. Set an interval from 15 min to 24 h on the check (
reminderIntervalSeconds) or as an organization default. The check's channels then getincident.reminder: "Still down after 1 h 15 min, not acknowledged (reminder 3)". Reminders are off by default and stop on ack, snooze or resolve. Twilio and PagerDuty channels never get them. - MCP: The MCP server lists 23 tools by default instead of 46. Tools are grouped in toolsets.
coreandstatuspagesare listed by default, andadmin(integrations and maintenance windows) must be requested. Add?toolsets=allto the endpoint URL to list them all, or useSP_MCP_TOOLSETSor--toolsetswith stdio. The 19 replaced tool names still work and are listed withtoolsets=legacy. - Helm chart: The Helm chart can run SolidPing on PostgreSQL through CloudNativePG. Set
database.cnpg.enabled=trueto create a CNPGClusterand read the connection string from its secret. The cluster surviveshelm uninstall. SQLite stays the default, so existing installs are not moved on upgrade. The Kubernetes install page now documents the chart. (#544)
Bug Fixes
- Discord: Discord buttons work when the bot has no Interactions Endpoint URL. Acknowledge, I'm unavailable and Escalate failed with "The bot did not respond quick enough", and nothing showed in the logs. Discord sends the button press over the Gateway in that case, and SolidPing ignored it. It now answers it like the HTTPS endpoint does, so the endpoint URL is optional when the Gateway is on. (#557)
- HTTP checks: An HTTP check with no expected status passes on any 2xx, not only 200. Healthy endpoints answering 201, 202 or 204 showed as down. Existing checks change too. To keep the old behaviour, set
expectedStatusCodes: ["200"]. - Database: A first start that was interrupted no longer leaves a database that never starts again. Stopping a container during its first start could end with
no such column: organization.default_escalation_policy_uidon every boot. A failed or interrupted migration now stays pending and runs again. On SQLite, the first boot builds the database in a temporary file and renames it when done, and each upgrade first saves a backup (solidping.db.pre-<migration>.bak) when there is room on disk. (#549) - Web push: Push notifications keep working after the first restart. On a fresh install, the push key served during the first run was never stored, so the first restart made a new one and every browser subscribed in between stopped receiving pushes. The key is now stored before it is used. A browser hit by this can turn push off and on again. (#539)
- Server: HEAD requests get the same answer as GET. Every route answered
405 Method Not Allowedto HEAD, so external uptime monitors that probe with HEAD saw SolidPing status pages as down. - Checks: Run now during a run in progress now gives a result. The request was dropped and the region reported
running, so with a 1 h period the dashboard waited forever. The region now answersqueuedand runs again as soon as the current run ends. Editing a check while it runs no longer loses that run's result. - Server: A fresh instance idles at about 30 MiB of its own memory instead of 140. The seeded admin password was hashed with 64 MiB argon2id, and that memory stayed resident for minutes. Every login did the same. Measured on linux/arm64, RssAnon went from 141 to 31 MiB 60 s after first boot, and from 207 to 26 MiB 10 s after a login. The browser sample checks are now created disabled, and a Linux host with 2 GiB of RAM or less and no cgroup limit gets half its RAM as the soft memory cap (
SP_RUNTIME_AUTO_MEMORY_LIMIT=falseturns this off). - Authentication: On a host with 2 GiB of memory or less, password hashing uses the OWASP minimum argon2id profile. A login now allocates 19 MiB instead of 64 MiB (
m=19456 t=2 p=1). This applies only to single-process installs (SP_NODE_ROLE=all) where no argon2 cost is set. Existing hashes are converted at the next login.SP_AUTH_PASSWORD_ARGON2_AUTO_PROFILE=falsekeeps 64 MiB. - Onboarding: A fresh install seeds 11 sample checks that work, instead of about 75. Most samples targeted services nobody runs locally (databases, message brokers, game servers, test FTP servers), so about half of them were down and the first dashboard was red. The 11 seeded checks are HTTP, DNS, SSL and TCP checks against public services. Every sample is still in the create-check picker, and test and demo modes seed them all. (#537)
- Logs: Logs pick a readable format on their own. The new default format,
auto, gives JSON on Kubernetes, colored output in a terminal or in a container, and plain text otherwise.SP_LOG_FORMAT=json,textorprettystill wins. The colored format has a timestamp and keeps attributes in a stable order.NO_COLORandFORCE_COLORare honored.LOG_LEVELis no longer reset to info once the configuration loads. (#536) - Logs: A fresh server prints a few lines at startup and the URL to open. It used to print 541 lines in its first minute and never said where to go. Check runs are DEBUG now. An INFO line is written when a check goes down, comes back up, warns or goes stale, and names the check. Workers and sample seeding print one line each. (#538)
- Logs: A fresh start no longer warns about normal things. The SQL errors on a missing table, the worker hostname, the retention setting, Sentry and Redis warnings are gone or moved to DEBUG. The metrics endpoint gets one line instead of two that contradicted each other. A fresh start now prints one WARN: credentials encryption is disabled. (#539)
- Authentication: Signing in on the demo org no longer files a join request. A visitor who signed up from the live demo was told "A join request was sent to its admins", and nobody ever answered it. Sign-ins on the demo org, and on the
defaultorg in SaaS mode, now land on the create-your-org screen. On self-hosted installs, sign-ins on thedefaultorg still send join requests to its admins. - Dashboard: A first-time visitor is no longer told their session expired. Opening
/or/d/in a fresh browser showed "Your session has expired. Please log in again." The visitor now lands on the sign-in page with no banner. An expired or revoked session still shows it. - Onboarding: The first check is no longer pre-filled with your mail provider. The empty dashboard suggests your email domain as the first URL to monitor, and skips free webmail. The skip list only had
.comnames, so agmx.de,yahoo.frororange.fraddress got its mail provider suggested. Country variants and national providers are now skipped too. (#532) - Webhooks: Webhook signing secrets work as is with the official Standard Webhooks libraries. New secrets are
whsec_followed by standard base64 with padding. The dashboard and API show older secrets in the same form. It is the same key, so receivers that already verify keep working and nothing needs rotating. A pasted secret that is not valid base64 now gets a 400 on create and update. - Configuration:
SP_AUDIT_RETENTION_DAYS=0keeps audit events forever, as documented. The value 0 was ignored, and the cleanup still deleted events older than 365 days. 0 now means "keep forever" in the env variable, inconfig.ymland in theaudit.retention_daysserver parameter. - Helm chart: Uploaded files survive a pod restart on PostgreSQL. With
database.type=postgres,/datawas not mounted, so logos, status page assets and screenshots were lost on every restart./datais now mounted whatever the database. (#544) - Dashboard: Toasts no longer cover the Run now, Disable and Edit buttons on a check page. After you created a check, the "Not on any status page yet" toast sat over these buttons. It is now a hint inside the check page, with its "Publish it" action. All toasts now start below the page header.
- Dashboard: Region events name the region. On the Events page,
region.offlineandregion.recoveredhad an empty detail. They now show the region name. (#552) - Dashboard: Page titles stay readable on a phone. On narrow screens, the header buttons squeezed the title to one word per line, for example on SLOs and Escalation policies. The buttons now wrap below the title. (#553)
- Dashboard: The status page editor works on a phone. Section headers and resource rows wrap instead of overflowing. (#554)
- HTTP checks: Forcing HTTP/2 against an HTTP/1.1-only server always gives the clear error. About 2% of such cleartext probes reported a generic connection error instead of "expected HTTP/2, but the server did not negotiate it". (#547)
- Server: The platform watchdog no longer flags daily checks as stale. A heartbeat check that runs once a day was reported as "silent for 95h" between the compaction of its last result and its next run. The watchdog now also reads the check's last result time. (#542)
- API: The number of check types is the same everywhere. The docs said 39 in one place and 43 in another. Every page now says 43.
GET /api/v1/check-typeshas a newexcludedFromTallyfield, true forsleepandprivate-location. The incidents API docs also list the parameters the server actually reads (state,checkUid,cursor).
0.38.3 — 2026-10-11
(diff)
Bug Fixes
- Dashboard: Shaking your phone opens the bug report. It was planned but never wired. A shake is three strong jolts within one second, a single bump does not count, and the next shake is ignored for 2 seconds. Only touch screens listen, so a desktop never sees a motion permission prompt. On iOS the motion permission is asked on the first tap. It only works when bug reports are enabled. (#524)
- Dashboard: List settings such as
expectedStatusCodesshow as chips on the check page. They used to be a separate bulleted block. They now sit in the settings grid, one chip per value, like in the edit form. (#523) - Dashboard: The incident state is translated. The "Recent incidents" card on the check page and the notifications page showed the raw
resolvedoractivein every language. They now read "Résolu" in French, and so on. (#525)
0.38.2 — 2026-10-11
(diff)
Bug Fixes
- Status pages: Public status pages show up much faster. On a page with 74 resources and 90 days of history, resources now appear in about 0.1 s instead of 1.1 to 1.9 s, and the browser is no longer frozen while it loads. The page loads in three stages: resources and active incidents first, then availability bars and response-time charts, then recent updates (last 7 days, with "Show older updates" for the rest). The first request starts before the JavaScript has downloaded. Bars and charts keep their space while they load, so rows do not move. A locked page still shows only the unlock form. (#504)
- Dashboard: The bug report screenshot shows what you see. On a scrolled page it used to be a strip from the top of the page, and at the bottom of a page it showed the top instead. It now captures exactly the viewport, with fixed and sticky elements in place and scrolled lists and tables as you left them. (#516)
- Dashboard: The login page is centered on mobile. The same fix applies to every sign-in, registration and password page. (#490)
- Dashboard: A tunneled check always names its bastion. On the checks list, the "via" tooltip showed a generic label when the bastion was not on the current page, for example after a search. It now fetches the bastion's name. (#512)
- Security: RDP checks no longer log the password at debug level. The RDP client logged its outgoing packets in hex, including the one carrying the password, and its session keys. It now logs only their length.
github.com/moby/go-archive(0.3.0) andgithub.com/moby/sys/user(0.4.1) are updated for two Dependabot alerts. (#514)
0.38.1 — 2026-10-09
(diff)
Bug Fixes
- Email: Transactional emails are readable in Gmail on Android dark mode. Links and addresses now carry their own color, so Gmail no longer recolors them. The fallback URL block is plain wrapping text, phones get a full-bleed layout, and light surfaces are flat. (#500)
- Incidents: Deleting a check resolves its open incidents. Every deletion path (single check, organization, test API) closes them with the new
check_deletedresolution. Deleting one check sends a single "check deleted" resolved notification, organization deletion sends none. A late result can no longer reopen an incident on a deleted check, and incidents orphaned by earlier deletions are closed by migration027_v0_39_0. (#500) - Integrations: Dashboard-created Pushover integrations deliver. The form and the sender now agree on
user_keyandapi_token, both stored encrypted, and existing rows are rewritten at startup. A missing setting is reported asINTEGRATION_MISCONFIGUREDwith the name of the setting instead of a delivery failure. The same fix applies to ntfy (server_url,priority,auth_token), and a test now fails when a form key is not read by its sender. (#500) - Dashboard: Port fields are easier to fill. Check forms use the real default port per type, show common-port chips for TCP and UDP, drop the number spinner, split a pasted
host:port, and show a server validation error only once the field was edited or the form submitted. (#500) - Dashboard: The bug report annotation works on phones and covers more. The text tool is an inline input instead of
window.prompt, charts appear in the screenshot, and the screenshot is cropped to the viewport. New pen, highlight and blur tools (blur is pixelated in the browser), four colors, redo, and select, move and delete. (#500) - system: Secrets entered on first setup are saved. The SMTP password, JWT secret and inbox password were silently dropped when no value existed yet. Host, username and from are trimmed, the mail page shows which fields an
SP_EMAIL_*variable overrides, the test email and real alerts use the same live configuration, and an SMTP 535 error says the username or password was rejected. (#500) - Integrations: Discord webhook integrations deliver. With no bot installed, a
webhook_urlnow sends incident embeds (the bot still wins when both are set). Saving a Discord integration with a URL that is not a Discord webhook now fails with a validation error. (#500) - Integrations: Self-hosted Discord setup is documented and shown in the dashboard. The Discord settings pages list the login and install redirect URIs and the interactions endpoint to register, with a warning when the base URL is still localhost.
SP_DISCORD_REDIRECT_URLis removed and only logs a startup warning if set. (#500) - ci: The end-to-end job time limit is raised from 45 to 60 minutes. (#500)
0.38.0 — 2026-10-05
(diff)
Features
- Status pages: The status page and its TV view show your organization's name and logo. The header displays the organization name, and the organization logo replaces the SolidPing mark when the page has no logo of its own (a page logo still wins). The TV board shows both above the headline. Only uploaded logos are shown; an external-URL organization logo is skipped because status pages only load first-party images. The public status page API gains
orgNameandorgLogoUrl. - Checks: New
vnccheck type. It connects to a VNC server, negotiates the RFB version (3.3, 3.7, 3.8), audits the offered security types (requireAuthdefaults to true), logs on with VNC authentication when a password is set, and captures a screenshot. Failures carry stable codes such asCONNECTION_FAILED,NOT_RFB,NO_AUTH_OFFERED,AUTH_FAILEDandNO_FRAME. - Checks: Scripted VNC remote control from JavaScript checks.
vnc.connect()opens a session with incremental framebuffer updates, pointer and key input, and pixel, region hash and screenshot reads. One session per execution, 100 actions at most. - Checks: The
vnccheck supports VeNCrypt (TLS) and Apple Remote Desktop. It picks the strongest security type both sides support that your credentials satisfy, and falls back to the next one on a fresh connection. New config keys:username,tlsVerify(default false),warningDays,criticalDays. The VeNCrypt certificate is reported and graded, with new failure codesTLS_FAILEDandCERT_EXPIRY. - Authentication: You can change your own email address, and a super admin can change anyone's.
PATCH /api/v1/auth/metakesemailandcurrentPassword;PATCH /api/v1/system/users/{uid}does it for another user. The change clears email verification, signs the user out of their other sessions, notifies the old address and records anauth.email_changedevent. Also available from the profile page and a new super-admin user edit page in the dashboard. - Checks: HTTP checks can require HTTP/2 or HTTP/3. The new
httpVersionoption takes1.1(default, unchanged behavior),2or3. With2or3the check goes down when the server does not negotiate that protocol, with an error such asexpected HTTP/2, got HTTP/1.1. HTTP/3 runs over QUIC (UDP), so it is refused on checks that go through a tunnel. Every HTTP result that got a response now carries the negotiated protocol inhttp_protocoland, when the server sent one, itsAlt-Svcheader inalt_svc, so you can see a server advertising HTTP/3 before you require it. The option is also in the dashboard as an "HTTP version" select. - Authentication:
SP_ADMIN_EMAILsets the email of the seeded super admin on the first start of an empty database, instead ofadmin@solidping.io. - Checks: Every check now keeps a version history, with diff and restore. Each change to a check's definition (dashboard, API,
sp apply, MCP) records a version with its author and origin, up to the last 100. New endpoints list versions, diff two of them, restore an old one, and approve or reject proposed changes. Secrets are never stored in a version and are kept as is on restore. The dashboard has a History page on each check, andcheck.updatedevents now carry the version number. - Checks: Describe a check in plain words and get a working
jscheck. An AI provider (SP_AI_PROVIDER,SP_AI_BASE_URL,SP_AI_MODEL,SP_AI_API_KEY; OpenAI-compatible or Anthropic) turns your prompt into a contract and a script, runs it until it passes, and stores it as a normal editablejscheck. The AI is never called while the check runs. When a check keeps failing because the target changed, a repair is attempted: withrepair: propose(default) it becomes a proposed version you approve or reject, withrepair: autoa verified repair is applied directly (at most once per check per 24 hours) and you get the diff. Secrets are never shown to the AI. Three MCP tools,run_js_script,fetch_pageandbrowser_snapshot, let your own agent write scripts. SaaS orgs have a daily token budget,maxAiTokensPerDay. The check's edit page shows its prompt, contract and repair mode and regenerates the script from them, testing it with the check's stored secrets (checkUidonPOST /checks/ai/generate). A screenshot asked for in the description is taken withpage.screenshot(). A probe script the AI runs while exploring is never saved as the check. Generation streams its progress (Accept: application/x-ndjson), so Describe it and Regenerate show each step, and a failure says what went wrong with the model's last message and attempt. A description using a secret the check does not have (secrets.PASSWORDwith noPASSWORD) is refused with aVALIDATION_ERRORnaming it, instead of running the login with "undefined". Describe it is also offered from the new check form when the server has an AI provider. - Checks: New
crawlcheck type: find broken links, mixed content and sitemap errors on a whole website. A crawl runs as short resumable slices (up to 20 URLs in about 10 seconds each), so it never blocks your other checks and a worker restart resumes it where it stopped. Options coverrespectRobotsand up to 20include/excludeprefixes or globs. The result lists the count per finding type, the first 50 findings andnewFindings(absent from the previous run), and incident notifications name the new findings first. The full report of the last 5 runs is downloadable from the check page orGET /checks/{check}/crawl-reports, andGET/DELETE /checks/{check}/runshows or cancels the run in progress. Each fetched URL counts as one execution against the checks-per-minute limit. A crawl runs from one cloud region only, never on a private location, with a minimum period of1h(default24h). - Checks: New
healthcheck type: read your application's health endpoint and get one result per component. It sends the same request as an HTTP check and understands Laravelspatie/laravel-health, Spring Boot Actuator, the IETF health check draft, ASP.NET Core HealthChecks, MicroProfile Health and any JSON with a top-levelstatus(format: autopicks the right one). A failure reads "Redis is down", not "assertion failed". One check keeps one incident, and a newincident.components_changedevent (no page) records when the set of failing components changes. - Checks: DNS checks can detect record changes. With
detect_changes: true, the first run in each region stores its answer as a baseline, and any record added or removed later is reported underchangesand turns the check down (or warning, withon_change). Each region keeps its own baseline because GeoDNS answers differ. Accept current records on the check page (or a PATCH withbaseline: {}) runs every region right away and captures the new answer. A PATCH orsp applythat omitsbaselinekeeps the stored one. - Checks: Run any check once, right away, with Run now. The check page header has a Run now button that runs the check in every region and shows the new status once each region has reported. Also available as
POST /api/v1/orgs/{org}/checks/{checkUid}/run-nowand as therun_checkMCP tool. Limited to 3 runs per minute per check and 60 per hour per organization. A region already running the check is left alone. - Integrations: New Slack (webhook) integration. Post incident notifications to a Slack incoming webhook (
hooks.slack.comonly), no Slack app needed. It is added to the default and critical severities of new organizations. The two Teams integrations are now labelled Teams (webhook) and Teams (bot), and a create error shows the server's reason. - Dashboard: The check page shows the configuration like the edit form. Settings are a key/value grid, a
jsscript is shown in the editor (read-only) instead of on one line, and the AI-authored block uses the edit form's panel. - Dashboard: Recent incidents on a check tell degraded from down. The table uses the Down / Degraded / SLO burn chip and colors of the incidents list, so a degraded incident no longer looks like an outage.
Bug Fixes
- Checks: Browser selectors match elements only.
page.text,page.click,page.fill,page.pressandpage.waitFor(and thebrowsercheck'swaitSelectorand keyword search) also matched the selector as plain text, sopage.text("h1")on a page whose inline style mentionsh1failed with "encountered an undefined value". Selectors are now CSS only, throughquerySelector. - Checks: A JavaScript check computing a
NaNmetric no longer loses its result. A metric such asDate.now() - startwithstartunset producedNaN, which cannot be stored, so the whole result was dropped. Non-finite metrics are now skipped and non-finite output values becomenull. - Checks: JavaScript check samples show the secrets they need. The Redis, bearer chain and browser login samples read secrets without declaring them, so the form showed no field to fill them in. Loading a sample now adds the secret rows (names only, values empty).
- ICMP checks: ICMP checks report the average round trip as their duration. The response time chart plotted the wall-clock time of the whole burst, so the default samples (
count: 2, 1 s interval) showed about 1 s plus the RTT, for example 1047 ms for a 10 ms reply. On success the duration is now the average RTT (the same figure asrtt_ms_avg); a burst with no reply keeps the elapsed time. Rows already stored keep their old values and age out (#466)
0.37.0 — 2026-09-30
(diff)
Features
- Dashboard: The check detail page and the checks list get a cleaner header. The check detail header is redesigned, and the checks list header and filter bar are tidied, with shared filter triggers and a scope filter (#464)
- MCP: The MCP server publishes a public server card with initialize instructions and a registry lint, so Smithery indexes it with its tools and capabilities (#464)
- deploy: add an MCPB bundle for Claude Desktop and Smithery (#458)
- ci: the backend coverage badge is published to the README (#464)
Bug Fixes
- Checks: Region freshness no longer warns about a region the check has left. After automatic placement moved a check out of Paris, the detail page kept showing "No result from Paris" with a stale age. Regions the check is not placed in are now ignored, both on the page and in the MCP diagnose tool (#464)
0.36.1 — 2026-09-29
(diff)
Bug Fixes
- agentws: The server no longer times out busy agents on its own keepalive pings. The event loop blocked on a ping while the frame reader waited on an unbuffered channel, so a data frame sitting ahead of the pong made the pong unreadable and busy agents were closed with
ping_timeoutevery few minutes (142 of 146 disconnects on one agent). Liveness now counts anything the read path sees (frames, pongs, the peer's pings), the pinger runs outside the loop, and a connection closes only after two consecutive probe cycles with no sign of life. Dead-agent detection moves from 12.5 s to about 50 s. Shutdown now recordsagent.disconnectedwith reasonserver_shutdown, and three metrics are new:solidping_agent_ws_conn_stale_total,solidping_agent_ws_conn_closed_total{reason}andsolidping_agent_ws_reconnects_total. No agent or wire change (#453) - checkers: Check probes now always speak HTTP/1.1, even to targets that offer h2. A probe cancelled over HTTP/2 left a stream that never completed in the pool, and every later probe rode that connection until
tcp_retries2gave up: one 1-minute blip cost 16 failed probes. Checks, RDAP, the CDP probe andGuardedHTTPClientnow share a pooled HTTP/1.1 transport (#453)
0.36.0 — 2026-09-28
(diff)
Features
- Dashboard: Events rows now name the private-location agent they are about.
agent.connectedandagent.disconnectedused to read identically whatever happened — same label,Systemas the actor, an empty Related column — so a day with 144 reconnects from a single agent never said which agent, which private location, or why it dropped. A row now shows the agent's name and its private location under the label, a disconnect spells out the reason (ping timeout,revoked,server shutdownorerror) instead of leaving the reader to guess, and the Related column links the agent to Organization → Private locations. The payload was already recorded and already returned; the dashboard simply never read it. An unknown or absent reason renders nothing rather than a raw machine code, and rows predating the payload are unchanged (#450) - Dashboard: The Organization layout header uses the shared page header, so the members page and every other Organization tab gets the same icon tile as the rest of the dashboard (#450)
Bug Fixes
- MCP: MCP tools now declare what they do and what they return. Every tool carries a
title, anannotationsblock (readOnly/create/update/replace/delete) and an object-rootedoutputSchema, and the server negotiates protocol2025-06-18alongside2025-03-26, so a client keeps the version it asks for. Clients that decide what is safe to run unattended can now read the annotations instead of inferring it from prose. A list tool that returned a bare JSON array now returns{"data": [...]}, and delete/replace tools return small objects such as{"deleted": true}— a response-shape change for any client that parsed the array directly. Descriptions were rewritten to state the return value, the side effects and the exact auth each tool needs (#448)
0.35.0 — 2026-09-27
(diff)
Features
- Dashboard: The events page's type filter now offers every event type SolidPing can record, grouped into families. It used to offer seven hand-picked types; it now lists the whole catalogue under family headings — Incidents, Authentication, Integrations, On-call, and the rest — with a translated label for each in English, French, German and Spanish. A test pins that catalogue to the server's own
EventType*constants, so a new event type cannot ship without turning up in the filter, and a hand-edited?type=naming a type the server cannot write is ignored instead of filtering the list to nothing (#445) - Incidents: An incident that has not ended yet tints its row in its kind's colour — light red for an outage, light amber for a degraded check, light violet for an SLO burn — so a list showing open and resolved incidents together separates what is happening from what ended at a glance. The tint sits one step lighter than the kind chip's own fill, so the chip still reads as the stronger element on top of it, and a resolved row keeps the card's background and the neutral hover (#445)
Bug Fixes
- Dashboard: The check detail Placement card no longer redraws once a second. The per-region
· 7s agochips and the fail-quorum sentence above them are gone: the regions are still named in the placement summary line, and a regional issue is still called out by the amber banner. Nothing about placement changed — only the card's ticking clock did (#445)
Documentation
- The Security & Encryption configuration page gains a Tokens Are Stored Hashed section: which credentials are stored as a one-way digest rather than encrypted — session refresh tokens, API tokens, invitation, registration and password-reset links, SSO handoff codes, status page kiosk tokens and agent enrollment tokens — and why SHA-256 rather than argon2id (they are CSPRNG output of at least 192 bits, verified on hot paths, while passwords and OAuth client secrets stay on argon2id). It also spells out the transparent upgrade, which hashes existing rows in place, and the warning that downgrading deletes every stored token. The configuration index now links to it (#445)
0.34.0 — 2026-09-27
(diff)
Features
- MCP:
solidping mcp --stdioruns an MCP server in-process on a local database, with no HTTP listener. It boots the database, migrations, jobs and check workers likeserve, and speaks newline-delimited JSON-RPC on stdin/stdout, with every log line on stderr. The session acts as the owner of the default org; pick another with--organd--user <email|uid>. An unknown org or user, or a user who is not a member, stops the command with a message on stderr and exit code 1. This is what MCP directories need: a single["solidping", "mcp", "--stdio"]command, with no token and no HTTP bridge. With the published Docker image, run it with--entrypoint /app/solidping(#443) - CLI:
sp mcpbridges stdio to a remote SolidPing's MCP endpoint, for clients configured with a command. Claude Desktop, Cursor and similar clients can now runsp mcp(alsosolidping client mcp) against solidping.io or a self-hosted instance, using the URL and token fromsp auth loginorSP_TOKEN. It keeps the MCP session across requests, closes it on exit, handles both JSON and event-stream replies, and renews an expired session token without prompting. The MCP docs have aclaude_desktop_config.jsonsnippet.solidping client ...commands now log to stderr instead of stdout (#443) - MCP: The public demo account can use MCP. Every MCP request from a demo session was refused with
403 DEMO_READ_ONLY, including theinitializehandshake. Demo sessions now get the same rights over MCP as over the REST API: all reads, pluscreate_check,update_checkanddelete_checkon checks the demo session created itself. Every other write tool answers a JSON-RPC error with the usual demo message anddata.codeDEMO_READ_ONLY.serverInfo.versionnow reports the real build version instead of0.1.0(#443) - Checks: Browser screenshots now capture the first screen at 1280×800 instead of the full page. Full-page captures of long pages sometimes never finished within the 5 s capture budget, and could push the browser container out of memory. A viewport capture takes about 1 s and 40 KB. Every browser session, JS checks included, now lays pages out at an explicit 1280×800 desktop viewport before the first navigation; before, Chrome's headless default of 800×600 applied. A keyword or
waitSelectorcheck on a site with a responsive layout may see different content at the new width.page.screenshot()in JS checks follows the same rule (#443) - Checks: A failed "Capture now" is reported instead of waiting 3 minutes. When the on-demand capture fails, the worker records why, the screenshots listing returns it as
captureOutcomenext todata, and the Screenshots card stops waiting and shows the reason. The 3-minute timeout remains for a run that never happens (#443) - Incidents: The incidents list shows the full title and the kind of each incident, and can filter by kind. Titles were cut after about 26 characters, which hid what happened for any check with a long slug. Each row now shows a kind chip (Down, Degraded, SLO burn) that reads independently from active or resolved, the full title, and the check name under it. The Check column is gone. A degraded incident that escalated links to the outage it caused. A new kind filter sits next to the state filter, backed by
GET /api/v1/orgs/{org}/incidents?kind=check,degraded,slo_burn; an unknown value answers400 VALIDATION_ERROR(#443)
0.33.0 — 2026-09-26
(diff)
⚠ BREAKING CHANGES
- API:
GET /metricsnow requires a bearer token, and answers 404 until one is set. The endpoint used to be open to anyone who could reach the port, and its labels exposed per-org check and incident activity and every org slug. SetSP_METRICS_SCRAPE_TOKEN(or themetrics.scrape_tokensystem parameter), then sendAuthorization: Bearer <token>from every scraper. In a Prometheus scrape config that isauthorization: { credentials: <token> }; the Observability docs have a ServiceMonitor example. A missing or wrong token gets 401.SP_PROMETHEUS_ENABLEDstays the master switch on top (#439) - MCP: OAuth confidential clients must now send their
client_secretat the token endpoint. The secret was issued at registration and never checked. A client registered withtoken_endpoint_auth_methodset toclient_secret_postorclient_secret_basicnow gets401 invalid_clientunless it sends the secret in the request body or with HTTP Basic. Public clients (none, which covers the dashboard and MCP-native clients) are unaffected. Registration also rejects an unknowntoken_endpoint_auth_methodinstead of silently treating it as confidential. If a client registered before this release never sends its secret, set theoauth.enforce_client_secretsystem parameter tofalse: failures are then logged at WARN and the token is still issued, until the client is fixed (#439) - Authentication:
POST /api/v1/auth/registeralways answers 200 with the same body, whether or not the email is already taken. The old409 EMAIL_ALREADY_TAKENtold anyone which email addresses had an account. A taken email now gets the same "check your email" response, and no email is sent. The confirmation token is now stored hashed, so a registration still pending at upgrade time fails its first confirmation and the user registers again, inside the usual 3-day window (#439) - Checks: The Better Stack importer rejects a
baseUrlfield with 400.POST /orgs/:org/checks/import/convert?source=betterstackused to send the caller's Better Stack token to whatever hostbaseUrlnamed. Better Stack has a single API host, so the field is gone. Remove it from any script that sends it (#439) - Integrations: Freebox pairing only accepts a Freebox address as
baseUrl, and no override at all on SaaS. The pairing handshake used to POST to anybaseUrlit was given. The host must now be a private LAN address (RFC 1918 or IPv6 ULA) or a*.freebox.frname, on port 80, 443 or 8443, with plainhttpallowed only in those two cases. Loopback, link-local and arbitrary DNS names are refused. The same rule applies to a settings update and to a stored address before the pairing-status endpoint dials it. WithSP_DEPLOYMENT_MODE=saas, only the defaulthttp://mafreebox.freebox.fris accepted and the dashboard disables the field (#439) - Status pages: Status pages now send a strict Content Security Policy, so external resources in custom CSS and incident Markdown no longer load. A
url()pointing at another host in the custom stylesheet, and an external image in an incident update, are blocked by the browser. Upload the logo through Branding, inline small assets asdata:URIs, or have the operator allow the host withSP_HEADERS_CSP_EXTRA_SOURCES(for exampleimg-src https://cdn.acme.com). A status page shown in an iframe on another site is now refused too: add that site's origin in the new "Status page embedding" card of the organization settings (API fieldstatusPageAllowedEmbedOrigins) (#439) - CLI: Release binaries are published compressed only:
.gzfor Linux and macOS,.zipfor Windows. New releases no longer carry the barespandsolidpingfiles, so a URL likereleases/latest/download/sp-linux-amd64returns 404 from v0.33.0 on. Downloadsp-linux-amd64.gzandgunzipit, orsp-windows-amd64.zip, which holds a singlesp.exe. The same applies tosolidping-*. The Windows build used to ship as.exe.gz, which Windows cannot open without an extra tool. Older releases keep their bare files (#439) - Checks: A check created without a region list is now placed automatically on 2 regions. Until now every check was pinned to the regions it was created with, which in practice meant the system default region, and a dead region stopped it. New checks default to
placement: autowithregionCount: 2, so each one counts as 2 runs per period againstmaxChecksPerMinute. If the org's limit cannot fit 2, the check gets the largest count that fits and the response says so. Send an explicitregionslist to pin a check as before. On upgrade, existing checks stay pinned, except those whose regions exactly match the systemdefault_regions: they switch to automatic with the same region count, so their cost and usual region do not change. Config-as-code exports an automatic check withplacement,regionCountandregionPool, and noregions(#439) - Incidents: A check running from 3 or more regions now opens an incident when a majority of its regions fail, not only when all of them do. Before, any passing result cleared the confirmation clock, so an incident needed every region to fail for the whole confirmation period. The new
failQuorumsetting makes the rule explicit. It defaults toallfor 1 or 2 regions (the same behavior as before) and tomajorityfor 3 or more. To keep the old behavior on a check with 3 or more regions, setfailQuorumtoall(#439) - Checks: On SaaS shared workers, a check target that resolves to a private address is refused. With
SP_DEPLOYMENT_MODE=saas, a check whose target resolves to loopback, RFC 1918, CGNAT, link-local (including the cloud metadata address169.254.169.254), IPv6 ULA or another non-public range now ends in Error with "target resolves to a non-public address, denied by egress policy". Run these checks from a private-location agent, where private targets are allowed by default. Self-hosted installs are unchanged. An operator can set the policy either way withSP_EGRESS_ALLOW_PRIVATE=trueorfalse, or theegress.allow_private_targetssystem parameter (#439) - Notifications: Notification URLs with embedded credentials are refused everywhere, and private URLs are refused on SaaS. Webhook, Gotify, ntfy, Matrix, Google Chat and Mattermost integrations now follow the same egress policy as checks. A URL carrying a username or password (
https://user:pass@...) is rejected with400 VALIDATION_ERRORon every deployment; move the credentials into a header or the service's own token setting. On a SaaS deployment, a URL that resolves to a non-public address can no longer be saved, and an existing one fails at delivery instead of sending. Self-hosted installs keep allowing private URLs by default (#439) - Checks: On SaaS, Docker checks only run from a private location. A Docker check on a shared worker could read that worker's own Docker socket. With
SP_DEPLOYMENT_MODE=saas, creating or updating a Docker check now fails with400 VALIDATION_ERRORunless it is pinned to private locations only, and an existing one on shared regions returns an error result without touching the socket. The check-type catalog shows this restriction up front. Self-hosted installs and agents are unaffected (#439) - Authentication: SSO logins no longer put tokens in the redirect URL, and
redirect_urimust be a relative path. Every federated login (Google, GitHub, GitLab, Microsoft, Discord, Slack, OIDC, SAML) used to land on the dashboard withaccess_tokenandrefresh_tokenin the query string, and followed anyredirect_uriit was given. It now redirects to/d/auth/complete?code=...with a single-use code valid for 60 seconds, redeemed throughPOST /api/v1/auth/handoff/exchange. Aredirect_urithat is not a same-origin relative path is ignored and the user lands on their dashboard home. Anything that read tokens from the SSO redirect must switch to the code exchange. The old?access_token=handoff and/api/v1/auth/slack/exchangekeep working for one release to cover rolling upgrades (#439) - Checks: The degraded detection dry run is removed, along with the
degradedWouldFireAtfield and the?wouldHaveFiredlist filter. v0.32.0 stamped a "would have been flagged degraded" time on checks with degraded detection off, and showed it as a banner and a checks-list filter. A check with detection off is now simply not evaluated. Detection stays off for checks that predate the feature and on for new ones; turn it on per check (#439)
Features
- Checks: a check that stops producing results now shows "No data" instead of keeping its last status forever. When a region died, its checks kept reading "up" on the dashboard, the status page and the badges, because nothing ever replaced the last result. A per-minute sweep now moves any enabled check with no result for
max(3 x period, 5 min)to the newstalestatus, shown as "No data since 13:41" in gray. It sends no notification and leaves open incidents open, with "monitoring interrupted" and "monitoring resumed" entries on their timeline. The first real result brings the check back. The check page lists each region's last result, status pages say "No data, last checked 13:41", badges render a gray "no data", and the API gains?status=stale,lastResultAtandwith=region_freshness. API clients with a strict status enum need to acceptstale(#439) - Availability: availability and SLOs now say how much of a window was actually measured. An 8-hour gap in an otherwise healthy 24-hour window reported a plain 100%, as if the check had been watched the whole time. Availability now compares the probes received with the probes expected for the check's period and regions, and returns the gap as
coverageandunmeasuredSeconds; the same window now reads about 67% coverage with no downtime. Downtime is counted over measured time only. SLOs exposedataCoverageand spend their budget over measured time. The dashboard flags low coverage next to an SLO attainment and on availability rows, chart tooltips say "measured 67% of the interval", and status page day and hour bars say how much of the bucket was measured (#439) - Checks: when a region goes offline, the orgs whose checks stopped are told within minutes. A dead region used to show up only in an hourly operator digest, and the orgs whose checks stopped running were never told. A per-minute sweep now marks a cloud region offline about 5 to 6 minutes after its last worker beat. Every org with a check that runs only from that region gets one email to its owners and admins listing the checks that are not being checked, then one recovery email with the outage duration, plus
region.offlineandregion.recoveredevents. The checks list, check page and check form show a banner, red when a check is not running at all, amber when it runs from fewer regions. Operators are notified at the transition throughplatform_watchdog.recipients, andGET /orgs/:org/regionsgainsstatusandofflineSince(#439) - Checks: automatically placed checks move to a healthy region when one of theirs goes dark. When the region sweep sees a region offline, every automatic check running there is moved, in place, to the next healthy region in its candidate order (org default regions, then system default regions, then the rest), and runs right away. The move respects the check's
regionPooland the region's capabilities (headless Chrome, IPv4, IPv6), and is recorded as acheck.placement_changedevent on the check page. A recovered region moves nothing back. Pinned checks and private locations never move. The check form now opens on "Regions: Automatic (2 regions)" with a "Choose regions" link to pin, the check page shows the placement and each region's last result, and the checks list has a "Switch to automatic placement" bulk action with a dry-run count (POST /api/v1/orgs/:org/checks/auto-placement). MCPcreate_checkandupdate_checkexpose the same fields (#439) - Incidents: a single failing region is now visible as a regional issue. A target unreachable from one region only used to make the check flip silently between up and validating, and never surfaced anywhere. When some regions fail but fewer than the check's
failQuorum, the check page shows an amber "regional issue" banner naming the failing regions and the rule, and the placement block marks when each region started failing. No incident opens and nobody is paged. The check form's Incident tracking section gains "Regions that must fail" (Default, All regions, Majority or a number), shown from 2 regions, with what it resolves to. The API returnsfailQuorum,effectiveFailQuorumand aregionalIssueblock, config-as-code carriesfailQuorum, and MCPdiagnose_checkreports the regional issue. Status pages show it as a warning (#439) - Agents: every private location now has a liveness monitor, so an offline agent opens an incident. When the agent of a private location stopped, its checks went quiet and nobody was told, although the org is the only one who can restart it. Each private location now comes with a monitor of the new
private-locationcheck type: up when all its agents are connected, warning when some are stale (named in the result), down when none is, citing when the last one was seen and why it disconnected. It notifies through the org's usual integrations, does not count toward the check limit, and is created for existing locations at upgrade. Deleting it turns it off for that location, and the Private Locations page offers a one-click re-enable. The page also shows each agent's online or offline state and each location's state. Agents now writeagent.connectedandagent.disconnectedevents (#439) - Checks: heartbeat and email checks are now evaluated centrally, not inside a region. A passive check was evaluated by a worker in one of its regions, so a dead region silenced it and a private-location region turned it into errors. The jobs node now evaluates every heartbeat and email check once per period, with no region involved. These checks no longer have regions: the region picker is gone from their form, a
regionslist sent through the API, MCP or config-as-code is accepted and dropped, and existing ones lose theirs at upgrade. A new passive check that has never received a signal now stays in "created" for its first 2 periods instead of going down at once (#439) - Checks: browser and JS checks get a Screenshots card on the check page, with a "Capture now" button. A check's screenshots could only be found by opening its incidents one at a time, and a failing run that opened no incident kept nothing. The check page now shows the latest capture (time, region, trigger, incident link) with older ones in a strip below. Failing runs that open no incident keep their capture too, the last 5 per check. "Capture now" asks the next run to take a screenshot whatever the verdict, and the card updates when it lands. It is limited to 1 request per check per minute and 20 per org per hour. The API gains
GET /orgs/:org/checks/:checkUid/screenshotsandPOST .../capture(#439) - Checks: HTTP checks can refuse redirects to another host, and record where a redirect chain went. A new
redirect_host_policyoption (anyby default, orsame-host) makes an HTTP check fail when a redirect points to a different host, instead of following it. The dashboard shows it as "Redirect host policy" next to "Follow redirects", and JS checks accept the same option asredirectHostPolicyonhttp.getand friends. Every redirected request now reports itsredirect_chainin the result. Wherever the egress policy refuses private targets, it now applies to every redirect hop, not only the configured URL (#439) - Status pages: public status pages move to the electric blue identity without overriding customer theming. Status pages kept the old palette after the dashboard changed. They now use the dashboard's neutrals and primary blue, and the subscribe and unlock buttons carry the blue gradient. There is no page glow or accent gradient on a customer's page, and a custom stylesheet still wins over every default. Four new theming variables are documented for the buttons:
--primary,--primary-foreground,--primary-gradientand--gradient-foreground. The browser theme color, the TV mode shell and the embed widget follow the same palette instead of the old crimson (#439) - Dashboard: the login, registration and password screens match the electric blue dashboard. The auth screens still used the old blue and crimson aurora. The aurora panel now sits on the sidebar navy with a cyan and blue glow, the login button uses the same gradient as every other primary action, and phone-width pages show a single SolidPing mark instead of two stacked ones. The 404 and no-org pages get the same treatment (#439)
- Email: notification and account emails use the electric blue identity. The email header, accent bar, links and primary button move from the old blue and crimson palette to the dashboard's navy and blue gradient. Every gradient has a solid fallback for mail clients that ignore gradients. Status banners are unchanged (#439)
- Documentation: the documentation site moves from pink to the electric blue identity.
/docsnow uses the dashboard's primary blue in both themes, the hero and footer use the dashboard gradient and sidebar navy, and the navbar has a thin gradient border (#439) - Dashboard: the dashboard's "Recent activity" card now looks like the Events page. The two rendered the same events differently: the card still used emojis while the Events page had moved to icons. They now share one table. The card hides its Actor column on narrow screens so it no longer pushes the page sideways at phone width, and the Events page gains the milestone descriptions the card used to show alone (#439)
- Localization: hundreds of dashboard strings that were still English are now translated in French, German and Spanish. The French login page said "Sign in with passkey". The 2FA login step, the passkey and 2FA account settings, the Discord integration, the check-type picker, the period select, form validation messages and many labels written straight into the code are now translated. On public status pages, the status update card and timeline are translated, with relative times in the page language. Spanish now uses "tú" consistently. A test now fails on any French, German or Spanish value left identical to English unless it is listed as intentional, and a lint rule flags English text written directly in the dashboard's JSX (#439)
- Dashboard: dashboard errors now reach PostHog error tracking when analytics is enabled. The PostHog project received no exception events at all, because exception capture was never turned on. Unhandled errors, unhandled promise rejections and
console.errorcalls are now captured, and crashes caught by an error boundary are reported once with their component stack or route. Credentials in exception messages and stack URLs are redacted, and a known email-scanner crash signature is filtered out. The Analytics docs describe exactly what is sent (#439)
Bug Fixes
- Checks: degraded incidents can now be opened on PostgreSQL, and turning detection off closes the open one. A database constraint from an older migration only allowed check and SLO incidents, so on PostgreSQL no degraded incident could ever be created in v0.32. That constraint is widened. Turning degraded detection off on a check now resolves its open degraded incident, and the Slack and email notifications say detection was turned off instead of "steady again" (#439)
- Checks: a browser check that fails by timing out now keeps its screenshot. A hung page or a selector that never appears is the failure most worth a screenshot, and it was exactly the one that lost it: the probe used its whole timeout, leaving the capture less than a second. The worker now grants the capture its own 5 second budget on top of the check timeout, on the server and on private agents. The check's own timeout is unchanged, so a slow target gets no extra time to answer (#439)
- Scheduling: per-minute background jobs now run every minute instead of about every 5. A job scheduled for later was only picked up by a 5-minute fallback timer, so SLO burn evaluation, snooze expiry, degraded evaluation and the other per-minute sweeps ran 240 seconds late on average in production. The job runner now wakes for the earliest pending job, including when an already-queued job is moved earlier. The "Processing stats" log line also reported its average delay in milliseconds under a seconds label; it now reports seconds (#439)
- Agents: region health no longer reports every private location as having no live worker. Region health only counted cloud workers, so every private location looked dead, and two orgs using the same location name were merged into one row. Private locations are now counted from their org's connected agents and listed per org. The
solidping_workers_activemetric is now actually set, and three gauges that were never written (solidping_check_up,solidping_check_status_streak,solidping_checks_configured) are removed. New metricssolidping_region_darkandsolidping_checks_staleare published (#439) - Authentication: refresh tokens, API tokens, invitation links and registration links are now stored hashed. A copy of the database used to be enough to take over sessions: refresh tokens, personal access tokens, MCP OAuth refresh grants and invitation tokens were stored in plain text. Only a SHA-256 hash is stored now, and the same goes for registration confirmation links. Existing sessions, tokens and outstanding invitations keep working through the upgrade. Downgrading to an earlier version deletes every stored token, which signs every user out and revokes every API token and OAuth grant (#439)
- Authentication: logging out now ends the session on the server.
POST /auth/logoutonly cleared the cookie, so anyone holding the refresh token kept a working session after the user logged out. The session is now deleted, and its refresh token is refused right away. API tokens are not affected by a logout (#439) - Authentication: two-factor codes are limited to 5 attempts per 15 minutes per user. After a correct password, TOTP and recovery codes could be guessed without limit. Both now share a budget of 5 attempts per 15 minutes per user, which requesting a new login step does not reset. Past it, the API answers
429 RATE_LIMITEDand the login page shows a translated "too many attempts" message (#439) - Authentication: an SSO login started from another org's login page no longer kicks a real member out with "session expired". When the org whose login page was used refused the user, they got a session attached to no org, and opening their own org then failed every request and bounced them to the login page. They now land signed in on an org they belong to, with a note that their join request was sent to the other org. An org-less session opening an org it belongs to also switches into it correctly (#439)
- Dashboard: PostHog session replay no longer records login tokens. With PostHog analytics enabled, session replay recorded the page's original URL after every SSO login, including the access and refresh tokens in its query string. Replay now redacts tokens, codes and single-use links (password reset, invitation, registration confirmation) from recorded URLs and events, and header and body capture is forced off. Tokens recorded before this release stay in PostHog, so operators who ran session replay should revoke the SSO sessions of that period. Server errors sent to Sentry also redact the same query parameters (#439)
- Dashboard: the dashboard sends a Content Security Policy, and every HTML page is protected against framing. No page sent a CSP or a framing policy. The dashboard now allows scripts only from itself (plus the configured PostHog host), and the docs, the
/openapiexplorer and the one-click acknowledge and unsubscribe pages can no longer be framed by another site. JSON API responses are unchanged. Operators can add sources withSP_HEADERS_CSP_EXTRA_SOURCES, described on the new Security Headers docs page (#439) - Rate limits: the anonymous bug report endpoint is rate limited and capped.
POST /api/mgmt/reporthad no limit, so anyone could fill storage with screenshots and flood the linked GitHub repository with issues. It now allows 5 requests per minute per IP, stores at most 100 MB of reports per org (SP_APP_FEEDBACK_MAX_STORAGE_BYTES,0disables the quota, over quota answers 413), and opens at most 20 GitHub issues per hour (#439) - Dashboard: logging out no longer logs 3 to 4 "token refresh failed" errors. The dashboard kept refreshing data and its live connection while the logout request was in flight, and each request raced into a token refresh that had nothing to refresh. Logout now stops background requests and closes the live connection first, while the logout request itself can still refresh an expired token so the session is really revoked (#439)
- Dashboard: delete confirmation buttons show a readable white label. The label color token they used did not exist, so the text fell back to the page color: dark navy on red in light mode. The token now exists, the red is slightly darker so white text passes contrast in both themes, and the design reference's delete example is red again (#439)
- Dashboard: the organization "Parameters" tab is renamed "Variables". In French, the Parameters and Settings tabs were both called "Paramètres". The tab holding
${param:KEY}values is now "Variables" in every language. The syntax, route, API and CLI names are unchanged (#439) - Dashboard: integration names are no longer cut to a few characters in the integrations list. The name column got none of the row's spare width, so on a phone it showed 89 of 349 pixels. It now takes the spare width, and on narrow screens the status badge moves under the name (#439)
- API: the OpenAPI spec lists every check type. Its check-type enums named only six types, so a generated client rejected heartbeat, email and database checks. The MCP
create_checkandlist_resultstools had the same kind of hand-written list. Both now follow the server's registry (#439) - MCP:
update_checkno longer claims an emptyregionsarray pauses a check. It resets the check to the default regions and the check keeps running. The description now says so and points atenabledto pause or resume.create_checkalso describes the real region fallback order (#439) - Billing & entitlements: the server no longer warns that variables it actually reads are ignored. At boot,
SP_ENTITLEMENTS_BILLING_UPGRADE_TOKEN_SECRET,SP_SYSTEM_AGENT_ENROLLMENT_TOKENS,SP_SUPPORT_RETENTION_DAYSandSP_TEST_PG_BINARIES_PATHwere reported as "Unrecognized SP_* environment variable is ignored". For the first one, the warning even suggested a different variable that would have weakened how upgrade tokens are signed (#439)
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) andspimage (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, sogo mod downloadstopped withthird_party/grdp/go.mod: no such file or directory. The release binaries andspdownloads were published normally, butlateststill 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.
checkrdpused 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. Optionalusername/password/domainfields 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 reachesjscheck scripts as a newrdpobject mirroringbrowser:rdp.connect(),click/type/key(Unicode input events, so the target's keyboard layout doesn't matter),pixel/regionHash/waitForStableassertions, and explicitlogoff()/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,UpsertCheckRequestor 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 (nilomitted from the document, not the resolved default), matching the existing rule forTracerouteOnFailure: 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
--brandtoken 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
includequery parameter (e.g.?include=availabilityor?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
configshape is now published as a JSON Schema, both over the API and in each release. The shape of a check's config — which fields akubernetesororaclecheck 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/schemalists every check type's schema andGET /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 ascheck-config-schemas.tar.gzalongside 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 validateremains the source of truth (#429) - deploy: SolidPing now has a one-click Coolify template.
deploy/coolify/solidping.yamlimports 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: apasswordpage still 401s anybody without the cookie, warm memo or not, and taking a page private hides it that instant. Public responses also gainedstale-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 BYper 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-Encodingentirely, 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(defaulttrue) 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 carriesContent-Encodingpasses through untouched (#429) - CLI: the
spbinary 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 — becausesp checks validatereaches 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 lightconfigsub-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.gztwins stay published alongside the bare files (#429) - CLI: every
spbinary now ships with a gzip twin, cutting the download from ~99 MB to ~12 MB. The bare-binary naming kept download URLs stable, but bare is also uncompressed — andsplinks 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 publishessp-linux-amd64.gzand friends — the same version-free names with.gzappended, listed insp-checksums.txtlike the rest — soreleases/latest/download/sp-linux-amd64.gzis the same stable contract at a third of the size; decompress withgunzipand the bare files remain the canonical, unpinned URLs. The install docs show the.gzroute (#424)
0.31.1 — 2026-09-22
(diff)
Bug Fixes
- CLI: the
sprelease downloads now have version-free names, and Windows gets a build. Every release published the CLI assp_<version>_<os>_<arch>.tar.gz— a name that changes with every release — so areleases/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-arm64andsp-windows-amd64.exe, with a singlesp-checksums.txt.https://github.com/fclairamb/solidping/releases/latest/download/sp-linux-amd64is now a stable contract no matter which version is latest, Windows users get a runnable.exewith 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-barrow's segments and theresponse-time-graphrow'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
checkGroupUidpicker 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, recordsrtt_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
discordcontact (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 + timeoutwhether or not packets are lost — previously one lost packet at a hightimeoutconsumed the budget and truncated the burst, reporting packets that were never sent as lost and inflating packet loss. A newrtt_ms_jitterfigure (standard deviation over the burst) is recorded alongsidertt_ms_min/max/avg(#411) - Dashboard: the ICMP burst settings are reachable from the form.
count,interval,packet sizeandttlwere 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_privateenvelope, but almost every reader still looked at the publicsettingsmap 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: @aliceinstead ofalice - 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/openapiSwagger 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 jobskeeps 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.
bodyAssertionstakeseq,neq,contains,not_containsandregex, each with an optionalignoreCase, and the dashboard form finally exposes the matchers — the flatbody_expect/body_patternfamily existed in the backend but no UI reached them. This is the difference between checking a health endpoint properly and not:body_expect: HEALTHYmatches "UNHEALTHY", so an ASP.NET Core service reporting itself unhealthy was reported as up.eqtrims surrounding whitespace so a trailing newline does not break a check,ignoreCasefolds at pattern-compile time so anchored regexes still behave, and it applies to JSONPath assertions too. The existingbody_expect,body_reject,body_patternandbody_pattern_rejectkeys 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 theinitializehandshake; ours replied401, which blocked a listing outright. Onlyinitializeandnotifications/initializedare 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/listand everytools/callstill 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=trueopt-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.dbmigrates 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.spnow 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 themonitor,tagandmonitor_tagtables alone. It never touchesheartbeat,notification,userorapi_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 nokuma.dband 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
rabbitmqcheck can now alert on memory and disk headroom before RabbitMQ's own watermarks trip. Inmanagementmode, 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 fromGET /api/nodesand grades them against two new two-tier thresholds:memoryUsedWarning/memoryUsedCritical(a percentage of RabbitMQ's high watermark, or a byte size) anddiskFreeWarning/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 missingMode(AMQP/Management) andManagement Portfields plus the four threshold inputs — management mode was previously API-only (#382) - Checks: JavaScript checks can run through an SSH tunnel. A
jscheck can now carrytunnelCheckUid, the same wayhttp/tcp/websocketand 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 thetcp/udp/websockethandles are dialed through the bastion — the target hostname is resolved on the far side, not by the worker — and a tunneledhttp.<method>()response now carriestunneled: 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(...), …), andbrowser.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()andwebsocket.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 ajscheck 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 RedisAUTHfollowed byPING, 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 atcpcheck cannot branch on a reply — which is the whole reason a script is involved. Every call blocks and returns a value, exactly likehttp.*andsleep: there are no events, callbacks or promises to reason about. Reads accumulateuntila delimiter, to an exact byte count, until an RE2patternmatches, or take the next chunk as it arrives; bytes cross into the script astextorhex, since a JavaScript string cannot carry raw binary. TLS is one option away, withtlsVerifyand SNI, and the connection reports the same version and cipher-suite strings atcpcheck does. As with thebrowserglobal, 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 asdown. Only a transport the operator disabled, or the check's own deadline expiring mid-call, throws; the latter reportstimeoutand 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:
tcpandudpchecks 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_encodingtaketext(the default, so nothing stored changes meaning),escaped(\r \n \t \0 \\ \xNN— the only way a form field can express a CRLF) orhex, so a DNS query, an NTP request or an SNMP GET can be configured as bytes rather than approximated as text. A newexpect_patterntakes an RE2 regex, compiled at save time so a typo is aVALIDATION_ERRORrather than a check that errors forever;expect_datakeeps 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 twowrite()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\xNNescapes instead of the?????encoding/jsonmade of it. Silence behind an open port is nowTimeoutwithno matching reply within 10s (0 bytes received)rather than a genericDown, because "port open, service mute" is the one thing this check exists to catch. Andtimeoutfinally means what it says: dial, TLS handshake, write and the wait for the reply share one deadline, where atimeout: 10scheck 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 dialed8.8.8.8:53andpool.ntp.org:123without 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
timeoutinstead of an error. A script still blocked in a socket read when the check's own budget expired could surface asscript 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/solidpingbooted but kept nothing: the final stage had noENVorVOLUME, so the SQLite database defaulted to/appand uploads (org logos, status-page assets, screenshots) to/app/data/files— both outside the mounted volume, both gone on the nextdocker run. The image now setsSP_DB_TYPE=sqlite,SP_DB_DIR=/dataandSP_FILESTORAGE_LOCAL_ROOT=/data/filesby default and declaresVOLUME /data, pre-seeded and owned by the nonroot user (65532:65532) so a fresh named volume is writable on first run. The old-eflags keep working as overrides — Postgres users still setSP_DB_TYPE=postgresandSP_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-byteOK, 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 exactOK(no trailing newline), loosen that comparison — it needs to acceptOK\nnow. 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, solevel,msgand every structured attribute stay buried in the log body instead of becoming queryable fields.jsonnow emits one object per record for collectors that parse stdout,prettyselects the colourized handler, andtext(or its aliaslogfmt) 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 totext: 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
dockercheck 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
spimage, despite its changelog promising one. ThePublish the sp CLIjob asked buildx forlinux/amd64,linux/arm64without ever setting Buildx up, so it ran on the defaultdockerdriver — which cannot build a multi-arch image at all — and failed withMulti-platform build is not supported for the docker driver. The four release archives and the main server image were unaffected and did publish; onlyghcr.io/fclairamb/solidping/spwas missing.Dockerfile.sp's builder stage is now also pinned to--platform=$BUILDPLATFORM: it already cross-compiles viaTARGETOS/TARGETARCH, so without that pin the arm64 target rango mod downloadand the compile under QEMU emulation to produce exactly the same binary, minutes slower (#371) - CLI:
sp --versionreported1.0.0on every build ever shipped.cmd/sphard-coded that string and never importedinternal/version, so all four-X …internal/version.Version=ldflags the release workflow passes were inert — the published v0.28.0 archives report1.0.0too. It now reports the stamped version, anddevwhen built without one (#371)
0.28.0 — 2026-09-13
(diff)
Features
- Checks: a
jscheck can now drive a real browser. A new synchronousbrowserglobal —goto,fill,click,press,waitFor,text,evaluate,url,cookies,screenshot— backed by the same headless Chrome thebrowsercheck already uses. Until now the two check types split the one workflow every synthetic-monitoring buyer asks for and neither could complete it: thebrowsercheck loads a page and matches a keyword but cannot click or type, and thejscheck 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 tohttp.getfor the authenticated call. One page per execution, 100 browser actions, andevaluateas 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 asdown; only infrastructure failures throw, so "our Chrome sidecar is down" stayserrorand 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 testand CI invokedgo test -short, under which 72 files across 21 packages self-skip — every*_postgres_test.goincluded — 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 newbackend-postgresjob runs the layer non-short with-p 1, andSP_TEST_REQUIRE_POSTGRES=1turns "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 teststays fast and-short; the live-network and Docker suites move behind aslowteststag on a nightly (#365) - Checks:
POST /orgs/:org/checks/validatenow 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 machinecode—REGION_FORMAT,UNKNOWN_TYPE,INLINED_CREDENTIAL,DUPLICATE_SLUG,UNRESOLVED_SECRET_REFand 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,/applyand/exportremain admin-only - Checks: a dry run can finally prove that nothing would change.
import/apply(and the "migrate from…" converters) now report anunchangedaction and counter per check:created=0 updated=0 deleted=0 unmanaged=0with Nunchangedis 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 answeredcreated=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. Anupdateentry now also names the fields that move, aschanges: [{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@parisand the long@acme/parisare the same region,expectedStatusandexpectedStatusCodesare 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; andescalationThreshold, 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 calledunchangedfor a file that genuinely differs - CLI:
spis downloadable. Every release now publishes prebuilt binaries for macOS and Linux on Intel and ARM, with checksums, plus aghcr.io/fclairamb/solidping/spimage carrying the CLI alone.sp checks validate config.yamlis 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. Releasev0.27.1published 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 diffnow asks the server for the reconcile plan instead of text-diffing two documents, so it prints one row per check —create,updatewith 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.--textrenders 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
chromedpemit JPEG, and was carried in a field namedPNG; the attachment store sniffs the bytes rather than trusting its caller, so it refused every one of them withscreenshot 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) orsecrets(JS) was rejected with aVALIDATION_ERRORnaming 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/:keyvalidates the key instead of letting Postgres answer. An uppercase letter, a space, a slash or a quote reached aCHECKconstraint and surfaced as a raw500 INTERNAL_ERRORquoting SQLSTATE 23514 — and on SQLite, which carries no such constraint, the very same request returned200and 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:400withVALIDATION_ERRORand the fieldkey, matching what the org-scoped parameters route has always returned (#365) - CLI:
sp checks validate --output jsonchanged shape. Each issue was previously serialized with Go field names —{"Where": …, "Message": …}— and is now{"slug", "field", "code", "message"}, matching the newPOST /checks/validatedocument response and adding the stablecodea 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
periodnow stores that type's own default instead of a flat 1 minute regardless of type.ssl,domain,dnsbl,jsandbrowsereach declare their own minimum interval (1h, 6h, 15m, 30s, 1m) and a default above it (6h, 24h, 1h, 1m, 5m); a create that omittedperiodfor 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 soGET /checks/exportproduced a document thatPOST /checks/importrejected withperiod for ssl checks must be at least 1h.POST /checks/validatenow resolves the same effective period asPOST /checks, so a validate response can no longer describe a different check than what create would actually write. Existing rows are backfilled: anssl/domain/dnsblcheck 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: strippedexport no longer carries a live ingest token. Anemailcheck's only config key istoken— 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 anysmtpcheck whosedelivery_topointed 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 omitteddelivery_tois rebuilt from thedelivery_check_uidthe 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 /checksandPATCH /checks/:uidaccepted"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 thenamekey and the resulting document failed its own import withmissing required key 'name'— a file the server produced that the server would not read back. A blank name is now refused with aVALIDATION_ERRORon thenamefield, 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 runsolidping 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/importlined 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=truenow 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 samecreated/updated/errorsthe 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 newcaveatsarray 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 inerrors, and was not counted — so the response readcreated: 0while 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 nocheck.createdevent is emitted for it. In the one case where the cleanup itself fails, the entry is reported withstate: "created-incomplete"naming the slug that really does exist — never silently as zero. The response's long-declared-but-never-setskippedfield now has a meaning too: entries whosedependsOnedges 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 likeosork8s.clusterpassed 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 a400 VALIDATION_ERRORonPOST /checksandPATCH /checks/:uidand 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/managedtosolidping-managed. That key contains a dot and a slash, which the label key rule above has never permitted, soPOST /checks/applyand 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 onsolidping.io/managed, switch tosolidping-managed
0.27.1 — 2026-09-09
(diff)
Features
- Dashboard: the dashboard now lives at
/dand public status pages at/s. The old addresses carried a0that 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/checksis nowsolidping.io/d/orgs/acme/checks, andstatus.acme.com/status0/acme/mainisstatus.acme.com/s/acme/main. Nothing you have already sent stops working:/dash0and/status0answer 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/dash0and/dare 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
200to 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 washttps://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./demoitself 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/demois refused, for the same reason/dash0already 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=truewas 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=trueand/dash0/orgs/<anything>?demo=true. All four now enter the demo on load,1andtruealike, 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, soL'organisation de FlorentandL'organisation de Florenceboth landed onlorganisation-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 simplyflorent: 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/orgsgains an optionalslugBase: unlikeslug, which is still taken literally and still answers 409 when it is already claimed,slugBaseis 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
viewerapart fromuser. 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 theuserrole with403 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 themuser. 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 auserstops 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 touser.
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
defaultone. 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
defaulthad 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 theredefaultis 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=requestsparameter 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.jsreturned 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/solidpingrefusing 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 withsorry, 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
netcatone-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:SP1carries the token in plaintext, andSP2is 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-checkrequire_hmacoption rejectsSP1outright, and the dashboard pairs that toggle with a prompt to rotate the token, because a check that ever acceptedSP1has already exposed the key that signsSP2. 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=valuepairs - 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_mswas 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 rawNotFound. ANOT_SERVINGresponse 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
PATCHthat 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_ROOTwas 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 returnedfile 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_detachedtimeline 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_.jsonfile 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/changeloglists 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 copiedCHANGELOG.mdinto 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}/tvfor the default page, and/tvon 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
/solidpingslash command works. It was registered in the app manifests and documented publicly but had no handler, so every documented subcommand —/solidping helpincluded — answeredUnknown command.check,comment,list,create,config,incidentsandhelpall now route through the same parser as an @-mention and answer privately in-channel, andcheckacknowledges 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: *withAccess-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 anAuthorizationheader 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 withVary: 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 tocurland every other non-browser probe — and it had noOPTIONSroute 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 /checksvalidates 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,falseor 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/registerpage (#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
localStorageand 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_ERRORinstead of500 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/validateno longer answersvalid: truefor a payload thatPOST /checksthen rejects. The two endpoints decoded into unrelated structs, so validate never even saw fields the create path refuses — includinginternal, 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
- Configuration: 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_atis 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.checksPerMinutedemand, 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
internalflag 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.internalis 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:
internalis 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
browsercapability 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.yamlis served with the requesting host as its server rather than a hardcodedhttps://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, honouringX-Forwarded-Protobehind a proxy; the cloud stays listed as a second entry and is never duplicated when the origin already is the cloud; and aVarykeeps a shared cache from handing anhttp://spec to anhttps://client (#265) - HTTP checks: embedded files are served with a real
Content-Type. The shared helper passed a whole path tomime.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-setContent-Typesuppresses Go's own content sniffing./openapiwas 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.
useChartWindowResultsgained anisEmptyPendingsignal, 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 samplingDate.now()once per region could straddle a millisecond boundary and reorder the merged series, turning one assertion unsatisfiable and itswaitForinto 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
/supportinbox in the dashboard (#246) - Email: the JMAP inbox consumer is safe to run more than once — inbound mail is deduplicated by
Message-IDbefore 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_levelis recorded and exposed on create and reopen, checks carry a liveflapState, 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=0genuinely 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_startrollup 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.createdbefore announcing the connection (#246) - Checks: the 100th auto-slugged check for one host no longer 500s, and
validatingis accepted in the status filter (#246) - Database: generic attachments moved out of the already-released migration 014 into a new 015 (#246)
- Configuration: 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,
apiFetchtolerates 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,nextCursororhasMore. They were never populated with real values; clients paginate withlimitplus 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
maxSlosentitlement (#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-Unsubscribeheaders 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-passwordendpoint 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 theagent_gcsweep, and a secondDELETEpurges an already-revoked agent immediately (#240) - Database: 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.capabilitiesmissing on a running instance. It has awarnmode (db.migration_guard_mode), a self-healing migration 014, and asolidping migrate repairCLI command (#240) - results: abandoned probes are reaped into a dedicated
abandonedstatus instead of lingering ascreatedorrunning. 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_RATEis 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
errgroupthat 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=50page cost ~900ms. Member enrichment is now opt-in viawith=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_fromis 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,checkNameis 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/jobsis closed down, and this breaks any script that used it to enqueue a job type other thansleep. The endpoint accepted any registered job type from any authenticated org member, including a read-onlyviewer. Two of those types are directly abusable:emailsends 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 — andwebhookissues 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 beingsleepalone; 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 issolidping jobs create --type …, which is now equally limited tosleepand 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 500INTERNAL_ERROR, and is now 400VALIDATION_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 reusingentitlements.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
/commentcommand 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
--controlsurface token instead of rendering the exact same colour as the page behind them, and the segmented control was extracted into a sharedSegmentedControlprimitive — 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-colormetas 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
/incidentslisting 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.createdcode 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
TimeAgocomponent — 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.
checkdnsblandchecksiphad 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
CheckTypeunion, 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
/status0SPA 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
tscand so failedbun 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#1really is the org's first incident. Soft-deleted incidents keep their number and numbers are never reused, so#42means 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 —
/statusfor one-line org health,/incidentsfor 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 viasetMyCommandsso 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
detailscolumn existed but nothing ever wrote to it — meanwhile the Slack formatter was already readingfailure_reasonout 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 underlast_failurewithout 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
:80listener gets the same treatment keyed on theHostheader, 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_identitiestable records who each org member is on a given Slack workspace, populated by an automaticusers.lookupByEmailmatch 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.createdandincident.escalatedprepend<@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-parisinstead 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 invalidatingforever 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_protocolwith 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/dbfaultpackage — 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
DocsLinkprimitive renders a discreet icon button in the page header, wired to the matching page under/docsfor 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
regionparameter shared by the chart, the Recent Results table and the duration stats, instead of separategraphRegionandresultsRegionkeys 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
base64inside the agent container. The shipped image is distroless — no shell, nobase64, notar— sokubectl exec,kubectl cpandfly ssh consoleall fail, which leftSP_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_KEYSfrom an env var or a Secret — and show a working extraction route: a throwaway enrollment Pod sharing anemptyDirwith 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/:orgnow answers 200 with a replacement session scoped to an organization the caller still belongs to — omittingorganizationsentirely 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/merather 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-*-foregroundtokens 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 104font-monocall 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
monthrollups, 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
getMebounded 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/$orgduring 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_ENABLEDbecame a tri-state switch — unset means "on if a token is present", leaving an explicittrue/falseas 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 —
durationMsin 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:
getCheckdeclares itswithquery 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-onlyviewer— could previously add members, change other members' roles (including promoting themselves toadmin) 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 a403 FORBIDDEN(#204) - CLI:
sp auth loginnow 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 anadminno 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_ERRORrather than failing silently at 3 a.m. on the first page (#204)
Features
- orgs: an
ownerrole sits aboveadmin, 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-orgor 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.jsdrops a live status block into any page — withdata-force-statusanddata-sizeattributes, 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
ipVersionoption, 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 carryip_versionacross with a warning about its both-families default (#204) - Checks: HTTP checks gain
verifySslandfollowRedirectsoptions, 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)
- Configuration:
SP_NODE_ROLEaccepts a comma-separated list, so one node can runapiandjobswithout 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_0rather thanv0_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/importround-trip a whole check catalog as YAML,sp checks diffshows what an import would change before it runs, andsp checks validateaccepts a complete export document offline — no server, no credentials — so a catalog can be linted in CI (#198) - Configuration:
SP_NODE_NAMEoverrides a worker's identity instead of deriving it fromos.Hostname(). Worker identity becomes a deployment decision rather than an accident of the container's UTS namespace, which unblocks running check workers under KuberneteshostNetwork: true— there the pod'sspec.hostnameis ignored and the node name, dots and all, is rejected by theworkers.slugconstraint. It also fixes silent slug collisions: two workers whose hostnames shared their first 15 characters previously collapsed onto oneworkersrow 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) - Configuration: 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 malformedSP_SYSTEM_AGENT_ENROLLMENT_TOKENSentry is no longer echoed into the logs either (#198) - Checks:
ValidateDocumentno 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_BASEvariable or a hardcodedlocalhost:4000, so they hit the local dev loop regardless ofE2E_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-clusterspec 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
settingsJSONB column onstatus_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-operatedkind='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)
- Database: 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 nestedreturnToone 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 viaSP_AGENT_KEYSso 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-backedtls_storagelayer (migration009_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 previewiframethat renders through the actual production status0 page, updated via a 300ms-debounced, origin-checkedpostMessage(no server round-trip).customCssis exposed end-to-end (DB column, storage services, API DTOs, CLI--custom-css/--custom-css-file, MCP tools), capped at 64 KB with@importrejected (externalurl()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-reschedulingagent_gcjob retires silentkind=systemagents 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.txtandllms-full.txtare 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:
maxCustomDomainswas enforced but invisible — it now appears on the org Usage page and in the wiki, and a cap of0renders as a real saturated0 / 0row 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 ofTLSStorageDelete) used a half-open key range (key >= 'certificates/' AND key < 'certificates0') that only matches under theCcollation — every common non-C collation (including glibcen_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 meantcustomDomainCertStatusstayed"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-independentLIKE '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
webhookUrlwhile the dashboard savedwebhook_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
removeChildcrash, 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_ipsandexpected_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
NXXwildcards (4XX,5XX, …) — the UI just never exposed it. The HTTP check form now uses a chip input: type or paste200 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 plain200-only check keeps saving with neither status key (today's implicit-default behavior, unchanged). Existing checks using the deprecated singleexpectedStatusfield keep working and migrate to the list on next save. The chip input itself is a new reusableTokenChipsInputshared 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
regionSpreadoverride — 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.1sfor near-simultaneous sampling to compare cross-region latency); validated0 ≤ spread < period, empty reverts to automatic spreading. Existing multi-region checks are migrated by an idempotent startup reconcile (migration007_v0_6_0,checks.region_spread). (behavior change: a multi-region check now actually executesregions ×more often and its checks-per-minute consumption is counted asregions × 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=grouponGET /checksorders 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 unknownsortvalues are rejected. (#156) - private locations (deported agents): a new
maxDeportedAgentsentitlement 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/bunroutertogo-chi/chibehind an in-repohttpxadapter 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 overmethod|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/ageX25519 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 viaSP_NODE_ROLE=agent(SP_AGENT_SERVER_URL,SP_AGENT_ENROLLMENT_TOKEN,SP_AGENT_KEYS_FILE,SP_AGENT_KEYS,SP_AGENT_NAME), and migration006_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}— plaintextspw_worker tokens, and theworkers.tokencolumn 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/tcponly) — 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 — settunnelCheckUid, 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 owntunnel_setup_msmetric 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-declaredsupportsTunnelcapability metadata rather than a hard-coded list. A bastion must haveexpected_fingerprintset 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 with409. 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
spCLI) can now revoke their own grant once their work is done, instead of it lingering until TTL — a standards-compliant RFC 7009POST /api/v1/oauth/revokeendpoint (advertised viarevocation_endpoint), a bearer-onlyDELETE /api/v1/auth/tokens/currentfor a client that no longer holds its refresh token,GET /api/v1/tokensnow marks which grant is "yours" (isCurrent), andsp auth logoutrevokes 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_KEYunset,GET/LISTresponses on checks and integration connections returned secrets (SSH private keys, HTTP basic-auth passwords, connection tokens) in plaintext to any org member, includingviewers. 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_KEYset, 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 joblog 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
basicAuthconfig key. Editing an HTTP check no longer silently wipes its stored secret headers. (breaking for API consumers readingconfig.username/config.passworddirectly: 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/minbudget, so a reload or a second tab tripped the limit. Defaults are now sized against that measured profile:requests_per_minute300 → 1800,burst60 → 360,rate_queue10 → 60,concurrency_queue10 → 40 (max_concurrentstays at 20 — a page-load burst of parallel fetches now parks briefly in the deeper waiting room instead of 429ing; deployments that setSP_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
429response'sRetry-Afterheader 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_tokencookie, which another app on the same host can shadow (browsers ignore ports when scoping cookies) — causing the socket to401forever 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 anAuthorizationheader on the handshake), with the cookie kept only as a fallback. (#145) - checks (SSH tunnels): with
SP_ENCRYPTION_MASTER_KEYunset, 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 onEnabled()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/:orgroutes 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: Bearertoken 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 readingmaxSsoUsersmust switch tomaxUsers) (#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, elseVALIDATION_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: Bearerheader or theaccess_tokencookie (header wins) and answers a bad/missing token with a plain HTTP401instead of upgrading-then-closing; the in-band{"type":"auth","token":…}message and theSP_REALTIME_AUTH_GRACEgrace 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,4401now 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
Selectfor a live-searchCheckPicker - 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 byuidonly now (API routes, dashboard links); old slug-form URLs return404(breaking: theslugfield is gone from create/update/response payloads) - CLI: the
spCLI 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
CollapsibleSectionprimitive 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/newquery-param prefill
Bug Fixes
- realtime: fix an EventNotifier listener leak —
GetJobWaitleaked one channel per processed job and eventually silenced the realtime WebSocket with zero logs; addsUnlisten, a non-stacking Postgres keepalive ping, and a listener-growth warning - Dashboard: unify the
useCheckquery 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_tokencookie 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.xreplies 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_uidthrough bun ($1→?) - aggregation: fix a poison-pill loop where marker-only result buckets re-aggregated forever, duplicating
hourrollup 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 globalperformance.*parameters - aggregation: fix an FK-orphan poison pill where a
resultsrow 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
jobstable no longer grows unbounded — a dailyjobs_cleanupjob soft-deletes finished jobs after 48h and hard-deletes them 24h later (retry-chains drained tail-first), with thresholds configurable viaperformance.*parameters
0.2.3 — 2026-07-10
(diff)
Bug Fixes
- Slack: send
conversations.list/users.listparameters form-encoded so Slack honorstypes/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
returnTodestination 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_verifiedgating 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 sharingmaxSsoUsersentitlement counting andUserProviderlinking (#118) - Authentication: per-org configurable max session duration —
auth.session_max_durationsystem 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-orgnow mints an org-scoped session token instead of leaving every API call 403'ing (#118) - Authentication:
auth.Servicereads overlaidAuthConfigfields (registration-disabled, session cap) live instead of from a frozen snapshot (#118) - Authentication: a zero-org session is kept alive —
/auth/mereturns no-org info instead of 401-logging-out the user (#118) - Dashboard: deep links survive login — every post-login path resolves through a shared
returnToresolver 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:
validateSearchboolean query-params (showSuppressed,graphFull) no longer silently no-op from string-vs-bool coercion (#118) - Checks:
createdlifecycle 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.logwithout 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-testsupervise 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 viaDISTINCT ON(#111) - e2e: dashboard E2E suite flaked on per-test login contention; authenticate once per Playwright worker instead of once per test (#111)
- Database: 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:4000in several spec files ignoredE2E_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
uuidcolumn 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 achecks/incidents/events/jobscollection 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 —
lanesmallint oncheck_jobs, hysteresis classifier, partial claim indexes, reservation-style two-SELECT claim with a reserved fast floor (fast_lane_reserved, borrowing allowed),busySlowaccounting + lane metrics (#89) - scheduler: heavy-check demand control — server-side
MinPeriodvalidation on create/PATCH (browser 60s, js 30s, global 10s floor; sleep/internal exempt), read-onlyschedulingblock (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)
- Database: 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_tokencookie 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/importadded (#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
MaxScanChunksfrom 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_settingsKV 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→urlinintegration_connections.settingsand 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)