Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- feat: the first step to workspaces ([ADR 0005](docs/adr/0005-workspaces-and-data-isolation.md)). An install that upgrades gets one workspace, "My workspace", and its users become its owners and instance admins. On a new install the first user to sign up gets it. Nothing changes in the app yet. `bun db import-mongo` gives the database it fills the same workspace
- feat: a request runs in a workspace ([ADR 0005](docs/adr/0005-workspaces-and-data-isolation.md)). A signed-in request names its workspace with an `X-Workspace-ID` header and runs in the user's first workspace without it; a workspace the user isn't in answers 404. An API key belongs to the workspace it was created in and runs there as an admin. `GET /api/v1/me` returns the signed-in user, their workspaces with their role in each, and the default one. Upgraded installs' API keys move to their one workspace and keep working. A self-hoster sees no change.
- feat: only an admin or owner of a workspace manages its API keys, and only an instance admin turns the usage ping and error reports on or off. The first user, and every user of an upgraded install, is one.
- feat: monitors belong to a workspace ([ADR 0005](docs/adr/0005-workspaces-and-data-isolation.md)). A monitor made through the API belongs to the workspace of the request, and its heartbeats, stats and TLS info go with it; another workspace's member gets 404 for it on every route. An upgraded install's monitors move to its one workspace, and `bun db import-mongo` puts imported monitors in the workspace it makes. Checks, pushes, probes, notifications, public status pages, badges and `/metrics` keep working across workspaces. A self-hoster sees no change.
- feat: account emails. Set `ACCOUNT_SMTP_HOST` (with `ACCOUNT_SMTP_FROM`, and `ACCOUNT_SMTP_PORT`, `ACCOUNT_SMTP_USER`, `ACCOUNT_SMTP_PASSWORD` and `ACCOUNT_SMTP_TLS` as needed) and the install sends the emails of an account from one sender. `POST /api/v1/auth/forgot-password` emails a single-use link that expires in an hour and answers the same for every address; `POST /api/v1/auth/reset-password` sets the new password with it and ends every session of the user, access tokens and refresh tokens alike. Every new account (the first user, a signup through an invite, later open signup) gets a verification link that expires in three days (`POST /api/v1/auth/verify-email`), a signed-in user can ask for it again (`POST /api/v1/auth/verify-email/resend`), and `GET /api/v1/me` returns `user.emailVerified` and `accountEmails`. An invite is also emailed to the invitee, and the copyable link stays. Asking for a reset or verification link is limited to 5 emails an hour per address and 20 requests per client address. `GET /api/v1/auth/config` is public and says whether account emails are on. With `ACCOUNT_SMTP_HOST` unset, which is the default, nothing changes: no reset link, no verification and invites are copied. See [Account Emails Configuration](apps/docs/docs/architecture/api-server.md#account-emails-configuration)
  - Upgrade note: the upgrade adds `users.email_verified_at` and `users.tokens_valid_after` and an `account_tokens` table, and counts every existing user as verified. Access tokens now carry their issue time to the millisecond.
- feat: signup modes. `SIGNUP_MODE` is `invite` (the default, and what an install that sets nothing gets: the first user signs up and everyone after joins through an invite) or `open`, where anyone may sign up and gets a workspace of their own, as its owner. Only the install's first user is an instance admin, and someone who signs up through an invite joins the inviting workspace instead. Any other value stops startup, and so does `open` without a sender for account emails (`ACCOUNT_SMTP_HOST`), since each new account must verify its email address. In `open` mode the workspace of a new account is suspended until its owner verifies their email address (or resets the password with the emailed link): its monitors can be created and are listed, but none runs, no check, missed-push detection or notification, and a push to its URL answers 403. `GET /api/v1/me` marks such a workspace with `suspended` and `suspendedReason`. In `open` mode a signup with an email that has an account answers 409, which tells the visitor that the address is registered. Open signups are limited to 5 an hour per client address, and `SIGNUP_TURNSTILE_SITE_KEY` with `SIGNUP_TURNSTILE_SECRET` makes `POST /api/v1/auth/register` need a Cloudflare Turnstile `turnstileToken` that the server checks. `GET /api/v1/auth/config` returns `signupMode`, `signupOpen` and `turnstileSiteKey`. See [Signup Configuration](apps/docs/docs/architecture/api-server.md#signup-configuration)
  - Upgrade note: the upgrade adds `workspaces.suspended_at` and `workspaces.suspended_reason`. Nothing existing is suspended, and an install that sets nothing behaves as before.
- feat: an admin or owner of a workspace renames it with `PATCH /api/v1/workspace` and a body `{"name": "..."}` (1 to 255 characters, trimmed). It answers 200 with the workspace as `POST /api/v1/workspaces` does, a member gets 403, and a workspace the caller isn't in answers 404. An API key renames the workspace it belongs to, as an admin.
- feat: the install email notification channel (type `install_email`, config `{"to": "..."}`) sends a notification through the install's sender for account emails (`ACCOUNT_SMTP_HOST`) to one address, which must be the email of a member of the channel's workspace. Creating, updating or testing one with another address answers 400, and so does creating, updating or testing one on an install without a sender. The address is checked again at each send: a recipient who has left the workspace gets nothing, and the failed send is logged. A send, a test included, also needs that member to have verified their email address; saving the channel does not. A test to an unverified member answers 400, and nothing is sent. The SMTP channel, with its own server and any recipient, is unchanged.

- feat: `DISABLED_MONITOR_TYPES` is a comma-separated list of monitor types that an install turns off, empty by default (the cloud sets `docker`, since the Docker monitor reads the Docker socket of the machine the server runs on). A type the server doesn't know stops startup and lists the known ones. Creating a monitor of a disabled type, or changing a monitor's type to one, answers 400 with the code `MONITOR_TYPE_DISABLED`. A monitor that already has such a type is kept and returned with `type_disabled: true`, but is not checked, not by the scheduler, a remote probe or a push (which answers 403), and nothing notifies for it; its stored `active` and probes are left alone, so it runs again once the variable is unset. A public status page lists it with `active: false`. `GET /api/v1/me` returns `disabledMonitorTypes`. See [Monitor Types Configuration](apps/docs/docs/architecture/api-server.md#monitor-types-configuration)
- feat: `BLOCK_PRIVATE_TARGETS` keeps monitors from reaching the internal network. It is off by default, so a self-hosted install is unchanged (the cloud turns it on). With it on, every monitor type that connects out is covered: the HTTP (including the OAuth token request, mTLS, NTLM and HTTP and SOCKS proxies), TCP, ping, DNS, SMTP, SNMP, PostgreSQL, MySQL, SQL Server, MongoDB, Redis, MQTT, RabbitMQ, Kafka, gRPC and Docker checks can't reach loopback, the private ranges and `100.64.0.0/10`, `0.0.0.0/8`, link-local addresses (cloud metadata at `169.254.169.254` among them), IPv6 unique-local and link-local addresses, multicast and broadcast; an IPv4-mapped IPv6 address counts as its IPv4 address, and Unix sockets are blocked. The address a check connects to is checked, so a hostname that resolves to a blocked address, a redirect to one and a changing DNS answer are all refused, and the check is Down with "target address is not allowed". With it on, creating or updating (`PUT` or `PATCH`) a monitor whose target is a blocked IP address, or a proxy whose host is one, answers 400 with the code `TARGET_NOT_ALLOWED`; a hostname is saved and checked at each check. `peekaping-probe` reads the same variable, and also blocks when the server it connects to does: the server states it in the new `block_private_targets` field of the probe hello answer, which an older server leaves out (off). A probe says in its hello that it follows the server's setting (`enforces_private_target_block`), and a server with the setting on deals no monitors to an instance of an older probe, which would ignore it: it logs a warning once for each such instance, and the probe API lists it with `enforces_private_target_block: false`. Upgrade the probes before turning the setting on. The protocol version is unchanged. A Docker monitor over a Unix socket is refused with it on. See [Blocking private targets](apps/docs/docs/architecture/api-server.md#blocking-private-targets)
  - Upgrade note: the upgrade adds `probe_instances.enforces_private_target_block`. Every instance on record counts as one of an older probe until it says hello again, which a probe does when it loses the server, as it does while the server restarts.
- feat: `BLOCK_PRIVATE_TARGETS` covers notification channels too. With it on, the webhook, Slack, Discord, Teams, Google Chat, Mattermost, WeCom, ntfy, Gotify, Matrix, Signal, Telegram, WhatsApp, PagerDuty, PagerTree and Grafana OnCall channels and the SMTP channel's server can't reach the addresses a check can't: a URL that is a blocked address, a name that resolves to one and a redirect to one send nothing. Creating, updating or testing a channel whose URL or SMTP host is a blocked IP address answers 400 with the code `TARGET_NOT_ALLOWED`; a test that dials a name resolving to a blocked address answers 400 with "target address is not allowed". The account emails and the install email channel are not checked. A proxy from the server's environment (`HTTPS_PROXY`, `HTTP_PROXY`) is the install's own and is connected to wherever it is, and a request's target is checked before it goes to the proxy. With it off, nothing changes. See [Blocking private targets](apps/docs/docs/architecture/api-server.md#blocking-private-targets)
- feat: `VERIFY_CUSTOM_DOMAINS` makes a custom domain prove that it points at the install before it serves its status page or gets a certificate. It is off by default, so a self-hosted install is unchanged (the cloud turns it on). With it on, a new domain is Pending: `GET /api/v1/status-pages/domain/{domain}` and the TLS ask endpoint answer 404 for it, and it keeps its name. It is Verified once a TXT record at `_peekaping-verify.<domain>` holds `peekaping-verify=<token>`, or the domain is a CNAME to `CUSTOM_DOMAIN_CNAME_TARGET` (the host of the public URL when unset; with a public URL on `localhost` or an IP address only the TXT record verifies). A check runs every 5 minutes (on the leader api replica in cluster mode): a Verified domain whose records are missing for 24 hours is Pending again, and a Pending domain is removed from its page after 7 days of checks that found it unverified (a pause of more than an hour in the checks, with verification off or the server down, starts the 7 days again). `GET /api/v1/status-pages/domains/{id}` shows a page's domains with their state, the records to add, the last check and when a Pending one will be removed; `POST /api/v1/status-pages/domains/{id}/{domain}/verify` checks one now. `GET /api/v1/me` returns `verifyCustomDomains` and `customDomainCnameTarget`. A `CUSTOM_DOMAIN_CNAME_TARGET` that isn't a host name stops startup, and so does a `VERIFY_CUSTOM_DOMAINS` that is not a boolean (such as `yes` or `on`), which would otherwise leave verification off. See [Verifying custom domains](apps/docs/docs/architecture/api-server.md#verifying-custom-domains)
  - Upgrade note: the upgrade adds `verification_token`, `verified_at`, `pending_since`, `failing_since` and `last_checked_at` to `domain_status_page`. The upgrade verifies no domain, so turning the setting on leaves Pending every domain that hasn't been verified, while one checked with its records in place beforehand (`POST /api/v1/status-pages/domains/{id}/{domain}/verify` works with the setting off) is already Verified; the Pending ones already pointing at the install verify on the next check, and a domain that doesn't has 7 days from that first check.
- feat: `API_RATE_LIMIT_PER_WORKSPACE` and `API_RATE_LIMIT_PER_API_KEY` limit the requests per minute a workspace, and one of its API keys, may make. Both are `0` (off) by default, so an install that sets nothing is unchanged. A request over either, signed in or by API key, answers 429 with `{"message": "Too many requests, try again later"}` and a `Retry-After` header in seconds; every request of an API key also counts toward its workspace's limit. Routes that run in no workspace (`/me`, `/auth/*`, `/admin/*`, creating a workspace, accepting an invite) are not counted. Each api replica keeps its own count, so an install with several replicas allows each replica's share. A negative value stops startup. The 429 of an invite, a password reset or verification email, and an open signup now send `Retry-After` too, and the API description lists the header on every 429 it documents. See [API rate limits](apps/docs/docs/architecture/api-server.md#api-rate-limits)
- feat: `CHECKS_PER_HOST_PER_MINUTE` caps how many checks to one target host run per minute in one checking process (the scheduler of `peekaping`, the api's push checks, or one `peekaping-probe` instance), across all monitors and workspaces. It is `0` (off) by default, so an install that sets nothing is unchanged; a negative value stops startup. Checks to a host are spaced evenly (60 a minute is one a second). A check whose turn is at most a quarter of its interval away, and at most 5 seconds, waits for it before its own timeout starts; a check further out skips that tick, with no heartbeat, no Down and no check slot held, and logs at debug level with the host and, at most once a minute per host, at info with the number skipped. The host is the one the check dials; push monitors and monitors under maintenance are never limited. A remote probe uses the stricter of its own `CHECKS_PER_HOST_PER_MINUTE` and the server's, which the server states in the new `checks_per_host_per_minute` field of the probe hello answer (an older server leaves it out, which means off, and an older probe ignores it, so the protocol version is unchanged). See [Spacing checks to one host](apps/docs/docs/architecture/api-server.md#spacing-checks-to-one-host)
- feat: `MIN_CHECK_INTERVAL` sets the smallest check interval and retry interval, in seconds, a monitor can be saved with. It is `20` by default, which is the smallest a monitor can have, so an install that sets nothing is unchanged; a value below 20 stops startup, since the timeout minimum (16) and the rule that a timeout is at most 0.8 times the interval rely on it. Creating a monitor or updating one (`PUT` or `PATCH`) with an `interval` or `retry_interval` below it answers 400 with the code `INTERVAL_TOO_SHORT`, the field and the minimum, and a message that names the minimum in seconds; a `PATCH` that sends neither is not refused because of a lower stored value. A monitor stored with less (saved before the minimum was raised, or imported) is checked at the minimum, by the scheduler, the stale-monitor watchdog and in the assignments a remote probe is dealt, while the API still shows what is stored; its timeout is kept as stored. `GET /me` returns the minimum as `minCheckInterval`, and the monitor form and Get started use it as their minimum and default interval. See [Minimum check interval](apps/docs/docs/architecture/api-server.md#minimum-check-interval)
- feat: workspace limits. A workspace's limits come from its own, which an instance admin sets ("no limits" is a value of its own), or else the install's defaults, the `WORKSPACE_LIMIT_*` variables, or else none: every variable is unset (`0`) by default, so an install that sets nothing has no limits. `WORKSPACE_LIMIT_MONITORS` caps the active monitors: creating an active monitor, or resuming one (`PATCH` or `PUT` with `active: true`), at the limit answers 403 with `{"error": {"code": "LIMIT_REACHED", "limit": "monitors", "max": 10}}`, and two such requests at once can't both take the last place. Lowering a limit pauses and deletes nothing: the oldest active monitors up to it run, the newer ones are kept but not checked, say `over_limit: true` and show as paused with the reason, and run again once the limit is raised or the owner pauses older ones. `WORKSPACE_LIMIT_MIN_CHECK_INTERVAL` applies beside `MIN_CHECK_INTERVAL`, on save and in the checks, and the stricter wins. `WORKSPACE_LIMIT_PROBES_PER_MONITOR` refuses more `probe_ids` (`LIMIT_REACHED`, limit `probes`), cuts the default probes of a new monitor to the first ones, and assigning a probe to every monitor skips the monitors at their limit. `WORKSPACE_LIMIT_STATUS_PAGES` refuses a status page create at the limit (limit `status_pages`), and `WORKSPACE_LIMIT_CUSTOM_DOMAINS` a create or update whose `domains` take the workspace's pages past it together (`custom_domains`). `WORKSPACE_LIMIT_MEMBERS` counts the members and the pending invites when an invite is made, and the members alone when one is accepted, signed in or through a signup, so a counted invite can still be used (`members`). `WORKSPACE_LIMIT_OWNED_WORKSPACES` refuses `POST /workspaces` to a user who owns as many, counted per user against the install's default alone (`owned_workspaces`). Each is safe against two writes at once. `GET /api/v1/workspace/limits` shows any member each limit with its value (`null` for none), its source (`workspace`, `install` or `none`) and the usage. An instance admin gives a workspace limits of its own with `PATCH /api/v1/admin/workspaces/{id}` and `{"limits": {...}}` (`{}` is no limits at all), removes them with `{"clearLimits": true}`, each logged, and the admin list shows every workspace's `limits`; `suspended` is no longer required there, but the request names at least one of the three. `WORKSPACE_LIMIT_RETENTION_DAYS` caps the data retention period: saving a longer `KEEP_DATA_PERIOD_DAYS` answers 403 `LIMIT_REACHED` (limit `retention_days`), one at the cap is saved, and the hourly cleanup deletes each workspace's heartbeats past the shorter of its own period and its cap, so lowering the cap trims the history on the next run; stats keep their install-wide retention. The workspace's Settings page shows the cap beside the field. See [Limits](apps/docs/docs/architecture/api-server.md#limits)
- feat: the install's default limits are install settings. An instance admin saves each under Admin → Settings, or with `PUT /api/v1/admin/settings` and `workspaceLimits` (`{"monitors": 10}` saves one, `null` clears it, a limit left out stays), unless its `WORKSPACE_LIMIT_*` variable is set, which wins and locks it (409 `SETTING_LOCKED`). A saved default is a whole number of 1 or more, `min_check_interval` at least `MIN_CHECK_INTERVAL` and `retention_days` at most 36500 (400 `INVALID_SETTING`), and it applies with no restart to every workspace without limits of its own, on the other api replicas within 5 seconds. `GET /api/v1/admin/settings` shows each with its value, source and lock. An install that sets nothing and saves nothing still has no limits
- feat: the web app words a write over a limit in the user's language, such as "This workspace can have at most 10 monitors running", when it creates, resumes or edits a monitor, creates or edits a status page, invites someone, accepts an invite or signs up through one, and creates a workspace. The monitor form, and Get started's first monitor, start at the shortest check interval that applies to the workspace. Admin → Workspaces shows each workspace's limits and where they come from, and sets its own, no limits, or the install's defaults again; Admin → Settings has the default limits, of every workspace without limits of its own
- feat: the admin area lists and suspends workspaces ([ADR 0005](docs/adr/0005-workspaces-and-data-isolation.md), section 10). `GET /api/v1/admin/workspaces?q=&page=&limit=` lists every workspace of the install with its name, when it was created, its owners' emails, how many members it has, how many monitors are active and paused, how many status pages and custom domains it has, and whether, why and since when it is suspended; `q` finds a name or an owner's email, ignoring case, and paging is that of `/admin/users`. It returns counts and metadata only, never a monitor's target, a channel or anything else inside a workspace. `PATCH /api/v1/admin/workspaces/{id}` with `{"suspended": true}` suspends a workspace and `false` resumes it: while suspended its monitors stop running (no checks, no missed-push detection, no notifications; a push answers 403) and `GET /api/v1/me` gives `suspendedReason` `admin`; nothing is deleted, and resuming brings it back as it was, with the monitors in the state they had. The admin's suspension is a hold of its own beside the one that waits for the owner's email verification, so resuming a workspace that still waits for verification leaves it waiting. Both are instance admin only (anyone else, and any API key, gets 403) and each suspend and resume is logged with who did it. Upgrade note: the upgrade adds `workspaces.admin_suspended_at`; nothing existing is suspended.
- feat: an instance admin deletes a workspace ([ADR 0005](docs/adr/0005-workspaces-and-data-isolation.md), section 10). `DELETE /api/v1/admin/workspaces/{id}` answers 204 and removes the workspace with everything in it: its monitors with their heartbeats and stats, notification channels, proxies, tags, maintenance windows, status pages and custom domains, API keys, invites and settings, by cascade, so no row is left behind. Its monitors stop at once, its public status pages and badges are gone, and its members' open sockets leave its rooms. Its members keep their accounts and their other workspaces; a member who had only this workspace gets `GET /api/v1/me` with an empty `workspaces` list. An unknown workspace is 404, anyone but an instance admin signed in as a user (and any API key) is 403, an admin may delete a workspace they are in, and the delete is logged with who did it.
- feat: install settings in the admin area ([ADR 0005](docs/adr/0005-workspaces-and-data-isolation.md), section 5). An instance admin changes the signup mode, the sender of account emails and the public URL without editing a compose file or restarting: `GET /api/v1/admin/settings` returns each with its value, where it comes from (`environment`, `saved` or `default`) and whether it is `locked`, and `PUT /api/v1/admin/settings` saves a partial change. Each value comes from the environment first, then the saved value, then the default, so a variable that is set (`SIGNUP_MODE`, `ACCOUNT_SMTP_*`, `CLIENT_URL`) wins and locks its setting: changing it answers 409 `SETTING_LOCKED`. Any `ACCOUNT_SMTP_*` variable that is set locks the whole sender. A value a setting can't hold is 400 `INVALID_SETTING` with the field; open signup without a sender is 400 `OPEN_SIGNUP_NEEDS_SENDER`, and removing the sender while signup is open is 400 `SENDER_NEEDED_FOR_OPEN_SIGNUP`. The SMTP password is never returned, only whether one is set, and a change that leaves it out keeps it, unless it changes the host, port, TLS mode or user: then the saved password is cleared and is entered again. `POST /api/v1/admin/settings/test-email` with `{"to": "..."}` sends a test email through the current sender (400 `NO_SENDER` without one, 502 with the SMTP reply code when it fails). A saved change takes effect at once, and on other api replicas within 5 seconds. Every change is logged with who made it and the settings it changed. An install that sets nothing and saves nothing behaves as before. See [Install settings](apps/docs/docs/architecture/api-server.md#install-settings)
- feat: Admin → Settings in the web app. An instance admin sets the signup mode (invite only or open), the email sender (host, port, TLS, user, password, from address) and the public URL there, and sees where each value comes from: the server's environment, saved here, or the default. A field the environment sets is read-only. The password field is never filled in: it says whether one is set, typing replaces it, and "Remove password" clears it. Save sends only the fields that changed, and a refusal says why, such as open signup without a sender. "Send a test email" checks the saved sender, sending to the admin's own address unless they type another. The usage statistics and error report switches moved here from a workspace's Settings page, and the upgrade notice links here
- feat: a workspace's Usage page. An owner or admin of a workspace with any limit opens it from Settings (`/w/<id>/settings/usage`): each limit with its usage, such as "14 of 20 monitors", and a bar that warns from 80% and more strongly at the limit; the shortest check interval and the data retention as values; and where the limits come from: the install's name for its defaults, or "Set by your administrator" for unnamed defaults and a workspace's own limits. A workspace without limits, and a member, have no such page. `WORKSPACE_LIMITS_NAME` names the install's defaults, such as `Free tier`, empty by default; it is an install setting, so it can also be saved under Admin → Settings in the default limits (or `PUT /api/v1/admin/settings` with `workspaceLimitsName`) unless the environment sets it, and `GET /api/v1/workspace/limits` returns it as `installLimitsName` (`null` when empty). There is no billing: nothing to upgrade or buy.
- feat: `SUPPORT_CONTACT` is how a member of a suspended workspace gets in touch with whoever runs the install: free text, an email address or a URL, empty by default. `GET /api/v1/me` returns it as `supportContact` (`null` when unset), for the suspension banner.
- feat: `METRICS_PORT` serves `/metrics` on a port of its own, on a second plain-HTTP listener, and the API port (and single mode's port) then doesn't serve it. It needs `METRICS_ENABLED=true` and a port other than `SERVER_PORT`, else startup stops, and so does a port that is taken. Unset, which is the default, nothing changes. The Helm chart adds a `metrics` port to the api Pod and Service. See [Prometheus Metrics](apps/docs/docs/architecture/api-server.md#prometheus-metrics)

### Changed

- feat: push tokens and custom domains are unique across the install, and the database enforces both. A custom domain is stored lower-case and trimmed, and looked up the same way, so `Status.Example.com` is `status.example.com`. A monitor without a push token stores none, and a push or API write with a token another monitor holds is refused with a 400 and the code `PUSH_TOKEN_EXISTS`, as a domain another status page holds is refused with `DOMAIN_EXISTS`. Deleting a monitor now deletes its TLS information and its certificate notification history too
  - Upgrade note: the upgrade stops, before it changes anything, when two monitors share a push token or two status pages share a custom domain (ignoring case and spaces). The error names the monitors, or the domain and the status pages, and leaves the push tokens out. Fix them in the version you run now: give all but one monitor of each group a new push token, or remove the domain from all but one of its status pages. Then upgrade again. A status page that lists one domain twice in different cases keeps the oldest, a domain that is empty is removed, and a monitor that has its push token only in its config gets it as its push token. `bun db import-mongo` fills a push token from the config the same way.
  - Upgrade note: the TLS information and notification history of monitors that no longer exist are removed. They were not shown anywhere.
  - `bun db import-mongo` stores no push token as none, clears a push token that an earlier monitor holds, normalizes domains and gives a domain that two status pages held to the first, and its report counts each.
- feat: a new monitor's form ticks the notification channels marked as default, which you can untick. A clone keeps its source's channels and an edit keeps the monitor's own. The first channel made in Get started is one.
- feat: the public status pages and the badges of a suspended workspace answer 503 with the code `STATUS_PAGE_UNAVAILABLE` and the message "This status page is unavailable", and say nothing of why, whichever hold the workspace is under, including a new account's workspace while it waits for its owner's email verification. A page that is not published still answers 404. They answered as usual before.

### Fixed

- fix: the Helm chart's api pods sleep 5 seconds in a `preStop` hook before they stop. The sleep gives the Service and the ingress time to stop routing to the pod, which reduces refused requests during a rollout. `api.preStopSleepSeconds` sets the sleep and 0 turns it off; the api pods get 40 seconds to stop by default (35 plus the sleep: 35 with 0, 47 with 12). A value that is not a whole number from 0 through 999999 stops the render. The probe pods are unchanged. See [Kubernetes (Helm)](apps/docs/docs/self-hosting/kubernetes-helm.md#rolling-restarts-of-the-api)
- fix: an HTTP monitor (HTTP, keyword and JSON query) saved without `max_redirects` now follows up to 10 redirects. Before, a missing value counted as 0 and the first redirect made the check Down with "redirects disabled". With `max_redirects: 0` the check follows none and judges the redirect response itself against the accepted status codes: a 302 is Up when `3XX` is accepted, and Down with "HTTP request failed with status: 302" when it is not. Before, it was Down with "redirects disabled" whatever was accepted. A negative `max_redirects` is still refused (PP-100)
- fix: a public status page showed a monitor that isn't checked (paused, or of a turned-off type) with the last status it had, and a stale Down or Maintenance still set the page's overall state. The page now shows "Not monitored" for the monitor and for each of its locations, dims its history bar, and leaves it out of the overall state. A page where no monitor is checked says "All Systems Operational", as a page with no monitors does
- fix: a boolean setting (`METRICS_ENABLED`, `BLOCK_PRIVATE_TARGETS`, `VERIFY_CUSTOM_DOMAINS`, `PROBE_ALLOW_INSECURE`) set to `TRUE` or `True` was read as off, without a word. They now read every form Go's `strconv.ParseBool` reads (`1`, `t`, `T`, `TRUE`, `true`, `True` and the false forms). A value that is none of them, such as `yes` or `on`, stops startup with a message that names the variable. Unset or empty keeps the default. A wrong value in a `.env` file stops startup even when the system environment overrides it
- fix: a step of the system clock during a check could make its response time negative, and a remote probe's result for it was refused by the server for ending before it started. Checks now measure on the monotonic clock, and a stored end time is the start plus the measured duration. Stored times stay in UTC, and a maintenance heartbeat's times are now UTC too
- fix: a number or duration setting (such as `MIN_CHECK_INTERVAL`, `REDIS_DB`, `QUEUE_CONCURRENCY`, `ACCOUNT_SMTP_PORT`, `BRUTEFORCE_WINDOW` or `PROBE_OFFLINE_AFTER`) set to something that doesn't parse (`many`, `1.5`, a duration with no unit such as `90`) used to print a warning and use the default. It now stops startup with a message that names the variable. Unset or empty keeps the default, and a valid value reads as before. A wrong value in a `.env` file stops startup even when the system environment overrides it.
  - Upgrade note: an install with such a value stops starting after the upgrade, with a message naming the variable; fix or remove it. The `.env` loader does not strip comments, so a trailing comment on a number line (`MIN_CHECK_INTERVAL=20 # seconds`) is part of the value and now stops startup: put the comment on its own line. In a Helm values file, quote large numbers: an unquoted value of 1000000 or more is rendered as `1e+06`.
- fix: a RabbitMQ monitor tries its next node when one hangs. Each node now gets a share of the monitor's timeout (what is left divided by the nodes left to try), where before a node that never answered used all of it and hid the healthy nodes after it

## [0.1.0] - 2026-10-28

### Added

### Changed

- chore: the Helm chart's bundled PostgreSQL and Redis (off by default) move to the Bitnami subcharts `postgresql` 18.12.16 and `redis` 28.3.10, with the latest PostgreSQL 18.6.0 and Redis 8.10.3 images
- chore: 0.1.0 takes the place of 0.0.49. It is the last release that also publishes the single-mode image under its old name, `peekaping-bundle-sqlite`, as 0.0.48 promised for 0.0.49
  - Upgrade note: switch `peekaping-bundle-sqlite` to `peekaping` in your compose file or `docker run` line. The old name gets no tags after 0.1.0. See [Upgrading to single mode](apps/docs/docs/self-hosting/upgrading-to-single-mode.md)
- fix: a failing monitor is checked again after its retry interval while it has retries left, and after its interval once they are used up. Before, the retry interval setting did nothing and every check waited the full interval. Max retries 0 or a retry interval of 0 keeps the interval (#582)
- fix: a new monitor's first failed check counts toward max retries like any other, so it is Pending while retries are left and Down after them. Before, the first failed check was Down at once and notified, even with retries left (#580)
- fix: a monitor's first heartbeat notifies only when it is Up or Down. A new monitor that starts Pending, or in a maintenance window, no longer sends a notification (#579)
- fix: the check timeout can be at most 0.8 × the check interval. The API answers 400 to a longer timeout on create, update and PATCH, and the monitor form shows an error under the timeout field. Before, the rule compared the wrong way round and PATCH didn't check it at all, so a timeout above the interval made the scheduler skip every second check (#584, #590)
  - Upgrade note: a monitor already stored with a longer timeout keeps being checked. Saving it again asks for a shorter timeout, or a longer interval.
- fix: notification channel URLs that hold credentials come back from the API masked, such as `https://user:********@host/x?token=********`. This covers the webhook URL, the ntfy, Gotify, WhatsApp and Telegram server URL, the Matrix homeserver URL, the PagerDuty integration URL and the Signal URL. Saving or testing a channel with the URL as shown keeps the stored one. To change a masked URL, type its credentials again: the form asks for them (#594, #595)

### Fixed

- fix: creating a push monitor through the API without a push token works. The server generates a token and returns it in `push_token` and in `config.pushToken`, where before it answered 400 (`PushConfig.PushToken ... required`). A token sent only as `push_token` is copied into `config.pushToken`, and a token you send is kept (#273, #574) (thanks @ausebiblibre)
- fix: a public status page shows a monitor that hasn't been checked yet, or is retrying, as Pending, not Down, and doesn't count it as an outage (#585)
- fix: the monitor page keeps getting live heartbeats. A room join sent right after the socket connected could be lost, and the page sent a leave and a join each time its data refetched, which could leave it out of the room (#592, #593)
- fix: TCP monitors work with a bracketed IPv6 host such as `[::1]` again (#118, #461) (thanks @miniwestxyz, @SynapShift)
- fix: PagerTree alerts for a monitor carry one stable `id`, so an Up resolves the alert its Down opened. The payload uses the lowercase keys PagerTree reads (#578)
- fix: each monitor's Grafana OnCall alerts go to an alert group of their own (`alert_uid` `Peekaping/<monitor id>`). Before, all monitors shared one group, and any monitor's Up resolved it while others were still Down (#576)
- fix: the ntfy topic is no longer logged, and email notifications are no longer printed to stdout with their recipient, subject and body (#577)
- fix: after a logout, the web app stops sending the previous user's access token. Before, it kept sending it until the page was reloaded (#583)
- fix: a wrong code when turning on two-factor authentication gets a 400 and an error, and two-factor stays off. Before, the API answered 200 and the web said two-factor was on when it wasn't (#586)

## [0.0.48] - 2026-10-27

### Added

- feat: `NOTIFICATION_HISTORY_RETENTION_DAYS` and `TLS_INFO_RETENTION_DAYS` set how many days the hourly cleanup keeps notification history (90 by default, at least 30, so the default certificate expiry notifications at 21 days aren't sent twice) and monitors' TLS information (30 by default, at least 1). They apply to the api and to `peekaping`, and a value below its minimum stops startup. Unset or `0`, nothing changes. See [Cleanup Configuration](apps/docs/docs/architecture/api-server.md#cleanup-configuration) (#75)
- feat: `bun db import-mongo --mongo-uri <uri> [--mongo-db <name>]` (or `MONGO_URI` and `MONGO_DB` in the environment, which keeps a password out of the shell history) moves a MongoDB install to SQLite or PostgreSQL. It applies the SQL migrations to the new database that `DB_TYPE` and the other `DB_*` variables name, then copies users (with their passwords and two-factor secrets), settings (including the keys that sign login tokens), API keys, proxies, monitors, notification channels, tags, maintenance windows, status pages with their domains, the links between them, heartbeats, stats, TLS information and notification history, in one transaction. It refuses a database that holds users, monitors or history of its own, and prints what it did with every collection, including what it skipped (such as links to deleted monitors) and what SQL has no place for (which monitors a status page hides). The monitors of a status page keep their order. It's in the single-mode image and in the migrate image. See [Move to SQLite or PostgreSQL](apps/docs/docs/self-hosting/docker-with-mongo.md#move-to-sqlite-or-postgresql) for the steps (#227)
  - Upgrade note: every row gets a UUID in place of its ObjectID. API key tokens and badge URLs that carry an old ID keep working, and so do logins and two-factor codes. Browsers that were signed in sign in once more, and scripts that put a monitor's ID in an API URL need the new ID.
  - Upgrade note: the login lockout counters (`login_state`) are not copied.
  - Upgrade note: open incidents in PagerDuty, PagerTree and Grafana OnCall won't resolve after the move, because their deduplication keys and fields carry the old monitor ID. Resolve them by hand. Webhook receivers that key on the monitor's `id` see the new ID, and links to `/monitors/<id>` in earlier Slack, Microsoft Teams and Google Chat messages no longer open the monitor.
  - Upgrade note: the source MongoDB must be 4.4 or later. On PostgreSQL, text wider than its column is cut and listed in the report, and two names that differ only after the cut get `~2`, `~3` at the end, so no row is lost. SQLite keeps text whole. The escape `\u0000` is removed from monitor and channel configs, which PostgreSQL rejects.
  - The target can be a database that Peekaping has started on once. It holds only the default settings that the first start writes, and the import replaces them with the install's settings, the login keys included. So a PostgreSQL that `peekaping` has started on is a target too.
- docs: pages for [Deployment modes](apps/docs/docs/self-hosting/deployment-modes.md), [Upgrading to single mode](apps/docs/docs/self-hosting/upgrading-to-single-mode.md) and [Kubernetes (Helm)](apps/docs/docs/self-hosting/kubernetes-helm.md). They say what single mode and cluster mode run, how to take a 0.0.44 to 0.0.47 bundle to single mode and back, and how to install the chart, from the published chart or from a repository checkout. The Docker + SQLite and Docker + PostgreSQL pages link to the upgrade page instead of each keeping its own copy of the upgrade notes
- feat: each release publishes the Helm chart at `oci://ghcr.io/0xfurai/charts/peekaping`, with the chart's `version` and `appVersion` set to the release, and every image tag defaults to the `appVersion`. So a chart version installs one consistent release, and you need no checkout and no `helm dependency build`: `helm install peekaping oci://ghcr.io/0xfurai/charts/peekaping --version <X.Y.Z>`. A component's `image.tag` still overrides the default. See [Kubernetes (Helm)](apps/docs/docs/self-hosting/kubernetes-helm.md) (#221)
  - Upgrade note: an install from a checkout still runs `latest`, and an `image.tag` you set still wins. A values file that pins all six tags can drop them when you install the published chart, because the chart version pins them. A tag left in stays on its release when you upgrade.
- feat: Peekaping sends anonymous [usage statistics](apps/docs/docs/self-hosting/telemetry.md) once a day and [error reports](apps/docs/docs/self-hosting/telemetry.md#error-reporting) when its own code crashes. Both are on by default, and an admin can turn each off on its own: the setup screen starts with both boxes ticked, and Settings has a switch for each. The usage ping holds the version, mode, storage backend, operating system, a range for the number of monitors and the kinds of monitors and notification channels in use. A report holds the error type, the stack frames, the version, mode, storage backend and operating system. Neither holds names, URLs, hosts, settings, error messages or IP addresses. Every field is listed on the page above, and the Settings card shows the exact JSON of the next usage ping.
  - Upgrade note: an install that upgrades starts sending each one for which no choice is stored. A choice an admin stored before, such as an opt-out, stays in effect: the new default applies only where nothing is stored. The admin sees a one-time notice in the app that says what is on, names what each sends and never sends, and links to the Settings cards. Dismissing it, or changing either card, stops it. It does not show when both are switched off by the environment.
  - To send neither, set both `TELEMETRY=off` and `ERROR_REPORTING=off`. `TELEMETRY=off` switches off only the usage statistics and `ERROR_REPORTING=off` only the error reports, each whatever an admin chose. In cluster mode, set `ERROR_REPORTING` on the API.
  - The dev compose files in this repository point both at an address nothing listens on, so a development stack sends nothing.
- feat: remote probes run a monitor's checks from another machine. Create a probe on the new Probes page, start the `peekaping-probe` image (`0xfurai/peekaping-probe`, also on GHCR) with its token, and choose the probe in the monitor's form. The probe connects out to the API over HTTP(S), runs the checks assigned to it and sends the results back as heartbeats. A monitor can run on several probes: each probe's results are a location on the monitor page, with its own status, response time and heartbeat bar, and by default the monitor is Down while any online location is Down. A status page can show each location. Several instances can share one token, and the probe's monitors are dealt among them. A probe that goes silent is marked offline after `PROBE_OFFLINE_AFTER` (3 minutes by default), the probe's own notification channels are told, and its monitors are not marked Down. The Docker Compose nginx gateway, the single-mode bundles on port 8383 and the Helm chart's Ingress and Istio VirtualService route `/probe/` to the API. See [Remote probes](apps/docs/docs/self-hosting/remote-probes.md) (#31, #32, #33, #34, #252, #36, #257, #253, #452, #453, #456, #457, #458, #459, #465, #466, #469, #470, #471, #476, #477, #478, #479, #486, #487)
  - Upgrade note: nothing changes for an existing install. The migrations add the probe tables, put every monitor on the local probe, and record the probe on heartbeats and stats; the heartbeats and stats you have read as the local probe's.
  - Upgrade note: with ingress-nginx, set `nginx.ingress.kubernetes.io/proxy-body-size: "2m"` in `ingress.annotations` before you use a remote probe, because a probe's batch of results can pass the controller's default limit of 1 MiB. A reverse proxy of your own in front of port 8383 has to pass `/probe/`, and allow 2 MiB bodies.
- feat: a monitor on several probes has a location rule that says when it goes Down: when any location is Down (the default, as before), when all of them are, or when at least N are. Locations whose probe is offline, or that haven't reported yet, don't count, so "all", or "at least" with N of 2 or more, keeps one probe's network trouble from raising an alarm while the other probes report. Choose it in the monitor's form once the monitor runs on more than one probe. Its Down and Up notifications name the locations, such as "Down at Frankfurt, Tokyo (2 of 3 locations)", and custom templates get `locations_down` and `locations_total`. See [Remote probes](apps/docs/docs/self-hosting/remote-probes.md#several-probes-per-monitor) (#35, #488, #489)
  - Upgrade note: every existing monitor gets the any rule, so it goes Down as it did before. A migration adds the rule's two columns to `monitors`.

- feat: the remote probe has a slim image, `0xfurai/peekaping-probe:slim` (or `<version>-slim`, also on GHCR), about 40% smaller than the full one. It leaves out the Docker, gRPC keyword, Kafka producer, MongoDB and SQL Server monitor types, tells Peekaping so when it connects, and so can't be given a monitor of one of those types. See [The slim image](apps/docs/docs/self-hosting/remote-probes.md#the-slim-image) (#264)
- feat: the full and the slim image can run under one probe token. Peekaping keeps the monitor types of each probe instance: a monitor is dealt only to the online instances that run its type, so a Docker monitor goes to a full instance and never to a slim one, and an instance can't send results for a type it doesn't run. The probe's types, which the monitor form and the API check a monitor against, are those of its online instances together, so a slim instance saying hello no longer takes Docker away from the probe. The Probes API shows each instance's `supported_types`. See [Full and slim instances under one token](apps/docs/docs/self-hosting/remote-probes.md#full-and-slim-instances-under-one-token) (#264)
  - Upgrade note: a migration adds `supported_types` to `probe_instances` and gives each existing instance the types of its probe, so nothing changes for a probe on one build.

- feat: probe availability. The Probes page shows each probe's online and offline time over the last 24 hours as a bar with its uptime, and the probe's name opens its own page, with its history over 24 hours, 7 days or 30 days and a bar for each instance's connections over the last 24 hours. The API serves it at `GET /api/v1/probes/availability` and `GET /api/v1/probes/{id}/availability`. In single mode the local probe is the `peekaping` process itself and has no history (PP-330)
  - Upgrade note: migrations add the tables `probe_status_changes` and `probe_instance_connections` and start each existing probe's history with its status at the upgrade. The hourly cleanup deletes status changes older than the data retention period.
- feat: Create probe has an "Assign to all monitors" box, ticked by default: when the probe's first instance connects, the probe is added to every monitor of a type it runs. The Probes page has the same action for a probe that exists. A new monitor now starts on every enabled probe that runs its type, not only on the local probe; a cloned or edited monitor keeps its probes. See [Remote probes](apps/docs/docs/self-hosting/remote-probes.md) (PP-324)
  - Upgrade note: on an install with remote probes, a new monitor runs on several locations under the default "any" location rule, so one probe's network trouble takes it Down. Choose "at least 2" in the monitor's form for three or more probes. A migration adds `assign_all_pending` to `probes`, false for every existing probe.
- feat: each heartbeat records the probe instance that ran the check, and the heartbeat tooltips and the important heartbeats list on the monitor page show "Instance:" and the instance's hostname. The heartbeat API, socket events and webhook payloads carry a nullable `instance_id` (PP-331)
  - Upgrade note: a migration adds the nullable column `heartbeats.instance_id`, empty for earlier heartbeats. It doesn't rewrite the table.
- feat: the CA is optional for mTLS on HTTP monitors. Without one, the check still sends the client certificate and verifies the server's certificate against the system's trusted roots. With one, nothing changes (#217)
- feat: every HTTP request gets an `X-Request-ID` response header (a valid one the client sends is kept) and one structured log line in every mode, not only with `MODE=dev`. The line names the route pattern, not the path, so push tokens no longer reach the log. Health and metrics requests log at debug level (#241)
- feat: `SQLITE_STARTUP_CHECK` chooses the SQLite check at startup: `quick` (the default), `full` or `off`. Cluster mode on SQLite logs a warning that single mode or PostgreSQL fits better, and the Helm chart refuses `DB_TYPE: sqlite`, which gave each pod a database of its own (#240)
- feat: the monitor list and the monitor page mark a probe that doesn't run the monitor's type, and the stale-monitor notification says why, such as "probe Frankfurt doesn't run docker monitors" (#263)
- feat: the version in the sidebar links to its GitHub release (#269)

### Changed

- feat: the docs at docs.peekaping.com match the landing page: dark by default with violet glows and the Geist font, a frosted navbar with the mascot, restyled sidebar, code blocks, tables and admonitions, and a welcome page with a hero, a copyable `docker run` and link cards. Bash, nginx, HTTP, PHP and Dockerfile code blocks are now highlighted
- feat: the landing page at peekaping.com has a new dark design: a hero with a live heartbeat card and a copyable `docker run`, a features grid, a quickstart terminal for SQLite and PostgreSQL, and monitor and alert channel lists that match what the server runs (MySQL/MariaDB, SendGrid, Twilio, LINE, PagerTree and Pushbullet were missing). The testimonials no longer repeat
- feat: the single-mode image runs Peekaping as one process, `peekaping`, which serves the web app, the API and the websocket on port 8383, with no Redis, Caddy or supervisord. The image now runs on Alpine. Port 8383, the data volume and your environment variables stay the same. There is no PostgreSQL bundle any more: PostgreSQL runs in a container of its own (see below). `peekaping` runs as the non-root `peekaping` user, and `PUID`, `PGID`, the Docker socket's group and the `ping_group_range` warning work as before. The image can also start with `docker run --user`, and it has a Docker health check. There is no MongoDB bundle any more (see Removed). The image, measured against 0.0.47:

  | | 0.0.47 | Now |
  | --- | --- | --- |
  | Image size | 417.5 MB | 78.6 MB |
  | RAM after 2 minutes idle | 92.7 MiB | 19.2 MiB |
  | RAM with 100 HTTP monitors checked every 20 seconds, after 5 minutes | 131.2 MiB | 36.6 MiB |

  See [Upgrading to single mode](apps/docs/docs/self-hosting/upgrading-to-single-mode.md), [Docker + SQLite](apps/docs/docs/self-hosting/docker-with-sqlite.md), [Docker + PostgreSQL](apps/docs/docs/self-hosting/docker-with-postgres.md) and [Single-mode user and permissions](apps/docs/docs/self-hosting/single-user.md) (#163, #209, #376) (thanks @tafaust)
  - Upgrade note, SQLite bundle: pull the new tag and start it with the same volume. Migrations run at start, as before. Redis and its data are gone; Redis held only the schedule and queued checks, which `peekaping` rebuilds from the database at start.
  - Upgrade note: a custom Caddyfile mounted at `/etc/caddy/Caddyfile` no longer works, because the image has no Caddy. Put your own reverse proxy (Caddy, nginx, Traefik) in front of port 8383 instead. The login lockout counts failed logins per client address, and the Caddy inside the image used to set `X-Forwarded-For` for it. Behind your own proxy, make it put the client's address last in `X-Forwarded-For` (nginx: `proxy_set_header X-Forwarded-For $remote_addr;`, because plain `proxy_pass` forwards the client's own header) and set `TRUSTED_PROXIES` to the address it connects from, so the API believes that header. Until you do, every login counts as coming from the proxy.
  - Upgrade note: port 8383 serves everything, and the image ignores `SERVER_PORT`. A `.env` that sets it, such as `SERVER_PORT=8034` from the examples, keeps working. To use another host port, change the published port, for example `-p 8080:8383`.
  - Upgrade note: `MODE` from your environment now applies, with `prod` as the default. Before, the bundles always ran with `prod`. With `MODE=dev`, `peekaping` logs in the development format and logs every request.
  - Upgrade note: `API_URL` from your environment now reaches the web app, in the `/env.js` that `peekaping` serves. Before, the bundles always gave the web app an empty `API_URL`, which means the API on the web app's own origin. If your environment or `.env` sets `API_URL`, unset it, unless the web app must call an API on another origin.
  - Upgrade note: `peekaping` takes up to 30 seconds to shut down cleanly. `docker stop` waits only 10 seconds by default, so use `docker stop -t 35`, or `stop_grace_period: 35s` in Docker Compose.
  - Upgrade note: with `METRICS_ENABLED=true`, port 8383 now serves `/metrics`, so anyone who can open the web app can read it. Before, Caddy didn't forward it.
  - Upgrade note: logs go to the container's output (`docker logs`). Nothing writes to `/var/log/supervisor` now, so a volume mounted there can go.
- chore: for development, `docker-compose.deps.yml` with `--profile postgres` replaces `docker-compose.sqlite.yml`, `docker-compose.postgres.yml` and `docker-compose.mongo.yml`. `make docker-sqlite` and `make docker-postgres` keep working
- chore: the Helm chart's bundled PostgreSQL and Redis (off by default) run pinned images, PostgreSQL 18.6 and Redis 8.10.2, instead of Bitnami's floating `latest`, so a rescheduled pod can't start a new major version on old data. The Bitnami subcharts move to `postgresql` 18.12.14 and `redis` 28.3.1, and `scripts/update-chart-deps.sh` checks and updates both. The migrate Job now tries up to 6 times (`migrate.backoffLimit`), enough to outlast a restart of the bundled PostgreSQL during an upgrade
  - Upgrade note: with the bundled databases on, the upgrade restarts PostgreSQL and Redis on the pinned images. The data stays, since the major versions stay 18 and 8.
- feat: the images of cluster mode run as a non-root user. The api, producer, ingester and migrate images run as `65532:65532`, like the worker, and the web image runs nginx as the user `nginx` (UID 101) on `nginxinc/nginx-unprivileged`, still on port 80. The SQLite compose files (`docker-compose.prod.sqlite.yml`, `docker-compose.dev.sqlite.yml` and the one in [Docker + SQLite](apps/docs/docs/self-hosting/docker-with-sqlite.md)) get a one-shot `data-permissions` service that makes `./.data/sqlite` writable by 65532 before `migrate` starts, on a fresh checkout and on a database that root created. The Helm chart's web pods set the sysctl `net.ipv4.ip_unprivileged_port_start` to 0 (`web.sysctls`), so nginx binds port 80 on any container runtime, and mount an `emptyDir` at `/tmp` instead of `/var/cache/nginx` and `/var/run`. [Cluster user and permissions](apps/docs/docs/self-hosting/cluster-user.md) says what the SQLite folder, Docker monitors and ping monitors need (#175)
  - Upgrade note: if you run SQLite in cluster mode from your own compose file, the database in your data folder belongs to root, and the new images can't write to it: `migrate` stops with `unable to open database file`. Give the folder to 65532 once with `sudo chown -R 65532:65532 .data/sqlite`, or take the new compose file, or add its `data-permissions` service to yours and make `migrate` wait for it (mount a named volume in that service too). PostgreSQL installs need nothing.
  - Upgrade note: Docker lets the unprivileged web image bind port 80. On a runtime that doesn't, the `web` container stops with `bind() to 0.0.0.0:80 failed (13: Permission denied)`: add `sysctls: {net.ipv4.ip_unprivileged_port_start: "0"}` to the service. The Helm chart sets it for you.

- feat: cluster mode on SQLite and PostgreSQL runs the api and `peekaping-probe` instead of the api, producer, worker and ingester: two processes fewer, and Redis only between the api replicas. The local probe's monitors are checked by `peekaping-probe` instances that hold the local probe's token (`PROBE_BOOTSTRAP_TOKEN` on the api, `PROBE_TOKEN` with the same value on the probe, `PEEKAPING_URL` pointing at the api). The api stores what it takes, a push to a push URL or a probe's results, on the replica that took the request, one monitor's results at a time and in order, and the stats of a heartbeat are counted once, by the replica that stored it. A batch a probe retries on another replica within the hour is answered as a duplicate, through a window the replicas share in Redis. The replica that holds the leader lock (the Redis key `peekaping:producer:leader`) runs the stale-monitor watchdog, the probe offline sweep and the push monitors' checks, and another replica takes over within about 15 seconds after a hard kill, and at once after a graceful stop. In cluster mode the local probe is online while one of its instances is connected, like a remote probe, so a stopped probe deployment raises the probe offline notification; if a cluster runs with no instance at all, the leader reports the local probe offline once, after `PROBE_OFFLINE_AFTER` (3 minutes by default, now read by the api), and logs `the local probe has N active monitors and no instance: deploy peekaping-probe with PROBE_TOKEN set to PROBE_BOOTSTRAP_TOKEN`. To check more monitors, add probe replicas. The producer, worker and ingester are removed in this release (see Removed). Single mode doesn't change. MongoDB installs move to SQLite or PostgreSQL with `bun db import-mongo` first (see Removed). The Helm chart's `producer`, `worker` and `ingester` values become `probe.*`, with a Secret `<release>-probe-token` that the chart makes and keeps across upgrades, or `probe.token.existingSecret` and `probe.token.existingSecretKey` for one of your own. The prod and dev Docker Compose stacks for SQLite and PostgreSQL get a `probe` service in place of the producer, worker and ingester, and stop at start until `PROBE_BOOTSTRAP_TOKEN` is in `.env`. `make dev` runs the api, a probe and the docs watcher. One api stores about 2,000 probe results a second on PostgreSQL 17 and on SQLite, where the ingester queue stored about 1,350 a second on PostgreSQL; a soak of 3 probe instances and 1,000 monitors at 20 seconds, with one instance stopped, missed no check and kept the api's heap at 14 to 17 MiB. See [Upgrading to probes](apps/docs/docs/self-hosting/upgrading-to-probes.md), [Probe](apps/docs/docs/architecture/probe.md), [Deployment modes](apps/docs/docs/self-hosting/deployment-modes.md) and [ADR 0002](docs/adr/0002-single-process-runtime.md) (#254)
  - Upgrade note, Docker Compose on SQLite or PostgreSQL: add `PROBE_BOOTSTRAP_TOKEN=<the output of openssl rand -hex 32>` to `.env` (at least 32 characters), take the new compose file from [Docker + SQLite](apps/docs/docs/self-hosting/docker-with-sqlite.md) or [Docker + PostgreSQL](apps/docs/docs/self-hosting/docker-with-postgres.md), and run `docker compose pull` and `docker compose up -d --remove-orphans`. The `probe` service replaces the `producer`, `worker` and `ingester`. If you keep your own compose file, delete those three services, add the `probe` service from [Upgrading to probes](apps/docs/docs/self-hosting/upgrading-to-probes.md), and move the Docker socket mount, `group_add` and ping `sysctls` of the `worker` to it. Run `docker compose up -d --scale probe=3` to check more monitors.
  - Upgrade note, Helm: remove the `producer`, `worker` and `ingester` blocks from your values file and set what they held on `probe` (`probe.replicaCount`, `probe.resources`, and `probe.sysctls` in place of `worker.sysctls`); a leftover block is ignored. `helm upgrade` creates the `<release>-probe-token` Secret with a random token and keeps it from then on. If you render the chart without the cluster (`helm template`, Argo CD), make a Secret with a token of at least 32 characters and name it in `probe.token.existingSecret`, or each render makes a new token.
  - Upgrade note: the order is to set the token, start the probes, upgrade the api, and remove the producer, worker and ingester. The compose files and the chart do it in one command, so no monitor goes unchecked for longer than the probes take to connect. A monitor goes stale only after three missed intervals.
  - Upgrade note: a cluster that runs with no probe instance has no checker for the local probe's monitors, so the leader reports the local probe offline once after `PROBE_OFFLINE_AFTER` and says what to deploy. Push monitors need no instance.
  - Upgrade note: the watchdog can't make a probe check again, so a stale monitor in this layout gets its notification once the wait has passed, without the "checking again" step. A monitor whose probe is offline is skipped, so a stopped probe deployment gives one notification, not one for each monitor.
  - Upgrade note: set `PROBE_OFFLINE_AFTER` and `ERROR_REPORTING` on the api replicas. The producer isn't there to read them any more, and the probe sends no error reports. `QUEUE_CONCURRENCY` and `PRODUCER_CONCURRENCY` no longer size the checks: `PROBE_CONCURRENCY` (128 by default) does, for each probe instance.
  - Upgrade note: MongoDB installs don't upgrade this way. They move to SQLite or PostgreSQL with `bun db import-mongo` first (see Removed).
  - Rollback: put back the previous compose file or run `helm rollback`, with the previous image tags. The upgrade adds no migration, so nothing in the database changes and there is nothing to restore. The local probe's token stays in the database and is harmless. If you came from a release without remote probes, restore your backup, because that upgrade also ran the migrations that add them.
- chore: the compose files, Dockerfiles and scripts in the repository root are named after the mode they run (PP-317). `docker-compose.prod.<db>.yml` is now `docker-compose.cluster.<db>.yml`, `docker-compose.bundle.<db>.yml` is `docker-compose.single.<db>.yml`, `Dockerfile.bundle.sqlite` is `Dockerfile.single` (the PostgreSQL one is gone, see below), `startup.bundle.sqlite.sh` and `startup.bundle.user.sh` are `startup.single.sh` and `startup.single.user.sh`, and `scripts/test-bundle-user.sh` is `scripts/test-single-user.sh`. The make targets follow: `make docker-prod-postgres`, `make docker-prod-sqlite`, `make down-prod-postgres` and `make down-prod-sqlite` are now `make docker-cluster-postgres`, `make docker-cluster-sqlite`, `make down-cluster-postgres` and `make down-cluster-sqlite`.
  - Upgrade note: this matters only if you run the compose files or make targets from a clone of the repository. After a `git pull`, use the new paths in your `docker compose -f` commands and scripts. A stack that you started from the old file keeps running, and the new file manages it, because Compose names the project after the directory, not the file.
- feat: single mode's PostgreSQL runs in its own container (PP-317). There is one single-mode image, built from `Dockerfile.single`, and it runs `peekaping` alone. With `DB_TYPE` unset or `sqlite` it keeps its data in `/app/data/peekaping.db`, as before. With `DB_TYPE=postgres` it connects to the PostgreSQL that `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS` and `DB_NAME` name, and `DB_HOST` is required. Any other `DB_TYPE` stops the container with a message. `docker-compose.single.postgres.yml` runs the image beside the official `postgres:17` image, with a healthcheck, `depends_on` with `service_healthy` and the data in `./.data/postgres17`, a folder of its own, so that the repository's single PostgreSQL Compose file never starts `postgres:17` on the old bundle's cluster in `.data/postgres`. `docker-compose.single.sqlite.yml` sets `DB_TYPE=sqlite` itself, so a `.env` shared with cluster mode can't switch it. `Dockerfile.single.postgres` and `startup.single.postgres.sh` are gone, and so is the `peekaping-bundle-postgres` image from the release workflows, the e2e jobs and the release candidate smoke test, which run the PostgreSQL case as the single image beside a `postgres:17` container. See [Docker + PostgreSQL](apps/docs/docs/self-hosting/docker-with-postgres.md#single-mode) and [ADR 0002](docs/adr/0002-single-process-runtime.md).
  - Upgrade note: `peekaping-bundle-postgres` gets no more tags. 0.0.47 is its last, and it stays on the registries, so a running install keeps working and a pull of that tag still works. To upgrade, move to the Compose layout with `pg_dump` and `pg_restore`, as [Moving from the PostgreSQL bundle](apps/docs/docs/self-hosting/upgrading-to-single-mode.md#moving-from-the-postgresql-bundle) says. The new stack keeps its data in `.data/postgres17` and leaves the bundle's `.data/postgres` as it is, so you can go back by starting the old container. Don't point the `postgres` image at the bundle's folder: its files belong to another user and were written on an older Debian release.
  - Upgrade note: a single-mode container now reads `DB_TYPE`. Unset or `sqlite` keeps SQLite, and `DB_NAME` is ignored, as before. `postgres` connects out to the server `DB_HOST` names. Any other value stops startup with a message, including `postgresql`, `mysql` and `DB_TYPE=mongo`.
  - Upgrade note: PostgreSQL's major upgrades go through the official image now, and single mode works with any PostgreSQL, a managed one included.
- feat: the single-mode image is now published as `0xfurai/peekaping` (and `ghcr.io/0xfurai/peekaping`), with the tags `latest` and `<version>`, for amd64 and arm64 (PP-321). It was `peekaping-bundle-sqlite`. `DB_TYPE` picks SQLite or PostgreSQL, as before.
  - Upgrade note: in `docker run`, replace `0xfurai/peekaping-bundle-sqlite:latest` with `0xfurai/peekaping:latest`. In a compose file, change the `image:` line the same way. The port, the data volume and the environment variables stay the same, so nothing else changes.
  - Upgrade note: `peekaping-bundle-sqlite` gets the same tags in 0.0.48 and 0.0.49, and none from 0.0.50. A setup that keeps the old name stops getting updates then.

- feat: the SQLite startup check runs `PRAGMA quick_check` instead of the full `integrity_check`, about 2 seconds instead of 30 on a 1.5 GB database, and the shutdown no longer runs a check at all. When the check finds damage, the process logs the findings and stops, instead of writing to a damaged database. See [SQLite recovery](apps/docs/docs/self-hosting/sqlite-recovery.md) (#231, #416)
  - Upgrade note: an install that runs on a corrupt SQLite database today, logging `Database integrity check FAILED` and carrying on, stops at startup after the upgrade, and a container with a restart policy keeps restarting. Follow the recovery page, or set `SQLITE_STARTUP_CHECK=off` while you do.
- feat: a probe with no notification channels of its own sends its offline and online notifications to the channels of its active monitors, each channel once. Before, it sent nothing, and its monitors went unchecked in silence. Create probe no longer asks for channels; Edit probe keeps them (PP-329, #549)
  - Upgrade note: installs whose probes have no channels start getting probe offline and online notifications on their monitors' channels. In cluster mode the local probe's offline notification reaches every channel of its monitors.
- feat: a probe holds at most 64 instances. A new instance at the cap takes the place of the oldest one that isn't online; when all 64 are online, the API answers 429 and the probe tries again after a minute (#258)
- feat: the setup screen asks for usage statistics and error reports with two ticked boxes and one short note that links to what's sent, instead of thirty lines of small print (PP-328)
- feat: the Type select of a notification channel lists the channel types alphabetically (#268)

### Removed

- chore!: remove MongoDB as a storage backend (the database Peekaping keeps its data in). `DB_TYPE=mongo` and `DB_TYPE=mongodb` are gone, with the Mongo repositories, the `peekaping-bundle-mongo` image, the MongoDB Docker Compose files (`docker-compose.bundle.mongo.yml`, `docker-compose.dev.mongo.yml` and `docker-compose.prod.mongo.yml`), the `mongo` profile of `docker-compose.deps.yml` and the `make` targets for them. [Move off MongoDB storage](apps/docs/docs/self-hosting/docker-with-mongo.md#move-to-sqlite-or-postgresql) is now only the guide for moving to SQLite or PostgreSQL. The MongoDB monitor type, which checks other MongoDB servers, is not affected, and neither is `bun db import-mongo` (PP-277)
  - Upgrade note: a MongoDB install has to move to SQLite or PostgreSQL with `bun db import-mongo` from this release before it starts on SQL, and it stays on 0.0.47 until then. The command is in the migrate image and in the single-mode image, and the [guide](apps/docs/docs/self-hosting/docker-with-mongo.md#move-to-sqlite-or-postgresql) has the steps for the MongoDB bundle, the Docker Compose stack and a server of your own. 0.0.47 is the last release that runs on MongoDB. The import reads a copy of your MongoDB data folder and changes nothing in it, so keep the folder until you are sure you don't need it.
  - Upgrade note: with `DB_TYPE=mongo` or `DB_TYPE=mongodb`, every Peekaping binary (the api, `peekaping` and `bun`) stops at startup with an error that says MongoDB storage was removed and gives the address of the guide, and so does the migrate image. The Helm chart refuses to render with either value. None of them writes to the database. A Docker Compose stack for MongoDB that pulls `latest` stops at the next pull, so pin its image tags to 0.0.47 until you have moved.
  - Upgrade note: `peekaping-bundle-mongo` gets no new tags. The images already published stay where they are.

- chore!: remove the producer, worker and ingester, which stayed only for MongoDB storage. On SQLite and PostgreSQL, cluster mode moves to probes in this release and they have nothing left to do (see the #254 entry above). The `cmd/producer`, `cmd/worker` and `cmd/ingester` programs, the `peekaping-producer`, `peekaping-worker` and `peekaping-ingester` images, the queue-based check and ingest dispatch (the `monitor:healthcheck` and `monitor:ingest` queues), their `make` targets and `dev:*` scripts and their three architecture pages are gone. Cluster mode is the api and `peekaping-probe` instances. The `notification` queue stays, and so does the leader lock key `peekaping:producer:leader`, so replicas of an earlier release and of this one still share one leader during a rolling upgrade. The api no longer copies the choice to send error reports to Redis, because nothing else reads it: each api replica follows the stored choice and applies a change made on another replica within a minute. See [Upgrading to probes](apps/docs/docs/self-hosting/upgrading-to-probes.md) and [ADR 0002](docs/adr/0002-single-process-runtime.md) (PP-277)
  - Upgrade note: the `peekaping-producer`, `peekaping-worker` and `peekaping-ingester` images get no new tags. 0.0.47 is the last release that has them, and the images already published stay where they are. On SQLite and PostgreSQL their work moves to the probes (see the #254 entry).
  - Upgrade note: a cluster that still runs them removes those services. For Docker Compose, delete the `producer`, `worker` and `ingester` services and add the `probe` service. For Helm, delete the `producer`, `worker` and `ingester` blocks from your values file. [Upgrading to probes](apps/docs/docs/self-hosting/upgrading-to-probes.md) has the steps. A stack that pulls `latest` keeps getting the last images published, not new ones.
  - Upgrade note: `PRODUCER_CONCURRENCY` is no longer read, so remove it from your `.env`. `QUEUE_CONCURRENCY` stays and now sizes only the api's `notification` queue. `PROBE_CONCURRENCY` sets how many checks a probe instance runs at once.

### Fixed

- fix: the monitor list, batch, create and update responses now include `notification_ids` and `tag_ids`, like `GET /monitors/{id}`, which now also returns `push_token` (#191)
- fix: in cluster mode, the hourly cleanup (heartbeats past the data retention period, expired stats, old notification history, old TLS information) ran on every api replica at the same time. It now runs once an hour however many replicas run: the replicas claim the hour with a Redis key (`peekaping:cleanup:<hour>`, kept for two hours, where the hour is the time rounded to the nearest hour in UTC, so replicas whose clocks are less than an hour apart share one claim), and only the replica that wins logs `Running the hourly cleanup`. The others log at debug level that another replica has it. A replica that can't reach Redis when the hour starts logs an error and skips that hour, and the next hour tries again. In single mode, `peekaping` runs the cleanup every hour in its process, with no Redis. The four cleanups are one job now, so they run one after the other, and the cron fires at the top of the UTC hour, which only moves the time in time zones offset by part of an hour. A failure to schedule the cleanup used to be ignored; it now stops startup with an error (#75)
- fix: security: the API lets a page read its responses only from the origins in the new `CORS_ORIGINS` setting. Before, it answered every origin with `Access-Control-Allow-Origin: *`, so a page on any site could read them. `CORS_ORIGINS` is a comma-separated list of origins, such as `https://status.example.com,http://localhost:8383`. Unset, it is the origin of `CLIENT_URL`, and `*` alone allows any origin. A request from another origin is still served; its browser just won't hand the response to the page. Responses no longer carry `Access-Control-Allow-Credentials`, which the API never needed because its token travels in the `Authorization` header. An entry that isn't an origin stops startup, and `/metrics` stays unreadable from another origin. See [API Server](apps/docs/docs/architecture/api-server.md#security-configuration) (#55)
  - Upgrade note: a setup where a page on an origin other than `CLIENT_URL`'s calls the API must set `CORS_ORIGINS` to that origin, or the browser refuses it. The web app the bundles, the cluster gateway and the Helm chart serve calls the API on its own origin and needs nothing. The dev Compose files set `CORS_ORIGINS` to `http://localhost:8383` for the web container.
- fix: security: the login lockout no longer trusts a client's `X-Forwarded-For`. It counted failed logins under the leftmost address in that header, so a client could send a different one with every request and never be locked out, in every deployment. The API now believes `X-Forwarded-For` and `X-Real-IP` only from the proxies in the new `TRUSTED_PROXIES` setting (comma-separated IP addresses and CIDR ranges; its own default is empty, which believes no header, as in the single-mode bundles, while the cluster compose files and the Helm chart set loopback and the private ranges when it is unset, and an explicitly empty value there trusts nobody). For a request from one of them, the client is the last `X-Forwarded-For` address that isn't a trusted proxy (the leftmost when all are), or the `X-Real-IP` when there is no `X-Forwarded-For`; an `X-Forwarded-For` it can't read makes the client the proxy's own address. A request from any other address counts as coming from the address it connected from. A second counter now locks an account whatever addresses the guesses come from: `BRUTEFORCE_ACCOUNT_MAX_ATTEMPTS` failed logins (100 by default) within `BRUTEFORCE_ACCOUNT_WINDOW` (10 minutes) lock the account for `BRUTEFORCE_ACCOUNT_LOCKOUT` (1 minute). The count of failures is kept until the window, measured from the first failure, has passed, so until then each further failure after a lock ends locks the account again: guessing is bounded to the limit per window plus one attempt each time a lock ends, and after an attack stops the owner is back in within the window at most. An attacker who keeps going can keep the account locked. A successful login resets both counters. A login request body over 64 KiB is refused with 413. The cluster compose files and the Helm chart set `TRUSTED_PROXIES` for their gateway, and the cluster `nginx.conf` now replaces the client's `X-Forwarded-For` with its address. See [Reverse proxy and HTTPS](apps/docs/docs/self-hosting/docker-with-sqlite.md#reverse-proxy-and-https) (#50)
  - Upgrade note: if you run Peekaping behind your own proxy, make the proxy put the client's address last in `X-Forwarded-For` (Caddy and Traefik do; plain nginx `proxy_pass` forwards the client's own header, so add `proxy_set_header X-Forwarded-For $remote_addr;`) and set `TRUSTED_PROXIES` to the address the proxy connects from. For a proxy on the same Docker host, publish port 8383 on loopback (`-p 127.0.0.1:8383:8383`) and use the gateway of Docker's network, such as `TRUSTED_PROXIES=172.17.0.1`; don't trust that address while 8383 is published on all interfaces, because direct connections can arrive from it too. Until you do, the API sees every login as coming from the proxy, so one person's failed logins can lock an account for everyone. Installs with no proxy in front, and the cluster compose files and the Helm chart as shipped, need no change. A cluster install with its own `nginx.conf` needs `proxy_set_header X-Forwarded-For $remote_addr;` in the `/api/` and `/socket.io/` locations, as the new `nginx.conf` has. An Ingress that passes the client's `X-Forwarded-For` through unchanged needs `config.env.TRUSTED_PROXIES: ""` in the Helm values. A malformed `TRUSTED_PROXIES` stops the API at start.
- fix: security: login tokens and API keys are verified more strictly (#54).
  - Login tokens are accepted only when signed with HS256. The signing secrets stay in memory and are read from the database again after a minute, instead of on every request, so a secret changed in the database is used within a minute.
  - API keys are checked with SHA-256 and a constant-time compare, which cost far less than bcrypt on every request. A key created before this change keeps working, and its stored hash is replaced on its first use.
  - A key with a usage limit is never accepted more often than its limit, also when requests arrive together: checking the limit and counting the use are one database update.
  - A refresh token issued before a password change no longer works, so the sessions signed in with the old password can't renew. An access token already issued stays valid until it expires, within 15 minutes by default.
  - Registrations that arrive together on a new install create one admin. The others fail with "admin already exists".
  - The websocket reads the access token from the socket.io `auth` payload. A token in the query string is no longer accepted, so it stops showing up in a proxy's access logs. The web app sends it that way.
  - Upgrade note: every user signs in again once, because refresh tokens issued by earlier releases are rejected.
  - Upgrade note: after its first use, an API key is stored as a SHA-256 hash, which earlier releases can't check. After a rollback, recreate the keys used since the upgrade.
  - Upgrade note: a script or tool that opens the websocket itself must pass the token as `auth: { token }` instead of `query: { token }`.
- fix: creating a monitor, notification channel or maintenance window with `active: false` now stores and returns it as inactive. Bun replaced the false value with the column's `true` default on insert, so creates always came back active; editing afterwards worked. Omitting `active` when creating still produces an active resource (#394)
- fix: the Helm chart's web pods copy the web app from the web image you set. Before, an init container always used `0xfurai/peekaping-web:latest`, so pinning `web.image.tag` still served the latest web app. The chart's install notes now say it runs cluster mode and link to the Kubernetes (Helm) docs page
- fix: on a fresh Helm install, the api, producer, worker and ingester pods no longer crash-loop until the migrate Job has created the database tables. They wait in an init container, `wait-for-migrations`, until the Job has applied every migration. On an upgrade, the new pods wait for the new release's migrations while the old pods keep serving
- fix: in the PostgreSQL Docker Compose files (`docker-compose.prod.postgres.yml`, `docker-compose.dev.postgres.yml` and the one in [Docker + PostgreSQL](apps/docs/docs/self-hosting/docker-with-postgres.md)), the api, producer and ingester start after the `migrate` service has finished. Before, they waited only for the database and Redis, so they could start before the migrations had run. If you use your own compose file, add `migrate` with `condition: service_completed_successfully` to their `depends_on`
- fix: `helm upgrade` no longer fails while the last migrate Job still exists. Each install, upgrade and rollback runs its own Job, `<release>-migrate-<revision>`, and Helm removes the previous one, so the Kubernetes (Helm) page drops the step that deleted the Job first
- fix: a migration that is interrupted at startup or in the Helm chart's migrate Job (`docker stop`, an out-of-memory kill, a lost node) no longer leaves the database locked on PostgreSQL and SQLite. Before, it left a row in `bun_migration_locks`, and every later start failed with `migrations table is already locked`. On PostgreSQL the lock now disappears with the migration's database connection, and a migration that starts while another one runs waits for it, up to 10 minutes (`MIGRATE_LOCK_TIMEOUT`), instead of failing. SQLite takes no lock. A database that is already stuck on that error starts after the upgrade, because a leftover row is ignored, so `bun db unlock` is no longer needed there. MySQL doesn't change. See [Migrate](apps/docs/docs/architecture/migrate.md#migration-locking) (#226)
  - Upgrade note: on PostgreSQL and SQLite, `bun db lock` no longer holds back `migrate` or `rollback`, because they don't read `bun_migration_locks` any more. If you ran `bun db lock` to freeze migrations during maintenance, stop the migrate container or Job instead. On MySQL, `bun db lock` still works as before.
- fix: SQLite's connection settings (foreign keys and the busy timeout among them) no longer drop after a cancelled query, so deletes keep cascading and writes wait for the lock instead of failing with "database is locked". Transactions now take the write lock when they begin (#265, #485; also #234, #256, #261)
- fix: in single mode, `peekaping` refuses a `peekaping-probe` that connects with the local probe's token, with a 401 whose message says why (the `peekaping` process checks the local probe's monitors, so use a remote probe's token). Before, such a probe was accepted and checked the local probe's monitors a second time. The probe logs the message, and `peekaping` logs a warning for each refused call. `peekaping` also no longer stores `PROBE_BOOTSTRAP_TOKEN`: it logs one warning at startup when it is set, and a value shorter than 32 characters no longer stops startup. See [Deployment modes](apps/docs/docs/self-hosting/deployment-modes.md#single-mode) (PP-315)
  - Upgrade note: a single-mode install that ran a `peekaping-probe` with the local probe's token must give that probe a [remote probe](apps/docs/docs/self-hosting/remote-probes.md)'s token, or stop it. A token that `PROBE_BOOTSTRAP_TOKEN` stored earlier stays in the database, and cluster mode accepts it again, but single mode refuses it.
- chore: `make help` and `make docker-configs` list only targets that exist: `make docker-dev-postgres`, `make docker-dev-sqlite`, `make docker-prod-postgres` and `make docker-prod-sqlite`, with the `down-*` targets, instead of `dev-*` and `prod-*` names and the missing `docker-status` and `docker-ps`. `make switch-to-postgres` and `make switch-to-sqlite` start the development environment instead of failing with "No rule to make target". Every Docker target in the Makefile, and the scripts under `test_utils`, run `docker compose`, so they work on a machine that has only the Compose plugin (PP-322)
- fix: maintenance no longer lowers the uptime % read from stats. Each check in a maintenance window counted twice in the total, so one Up and one Maintenance check showed 66.67% uptime instead of 100%. Maintenance checks count as Up, never as Down. This fixes the uptime % on the monitor page (24h, 7d, 30d, 365d and under the chart), the badges and each location on a status page; monitors that had maintenance show higher, correct numbers. A monitor's own 24h uptime on a public status page is read from heartbeats and still counts Maintenance as Down (PP-343)
- fix: security: a PostgreSQL monitor with `sslmode=verify-ca` or `verify-full` verifies the server's certificate. Before, it connected without checking any certificate. An unknown `sslmode` now fails the check, and a PostgreSQL or SQL Server query that isn't a `SELECT` is refused before connecting (PP-293, PP-294)
  - Upgrade note: a PostgreSQL monitor with `verify-ca` or `verify-full` against a server whose certificate doesn't verify goes Down. Use `require` to encrypt without verifying.
- fix: security: the notification channel API returns each saved secret (bot tokens, passwords, API keys, secret webhook URLs) as `********`. The edit form shows it as an empty field; saving or testing without retyping it keeps the saved secret, but only while the channel's type and endpoint stay the same, so a saved secret is never sent somewhere new (#124)
  - Upgrade note: a script that reads a channel's secret from the API gets `********` now. A script that sends a changed endpoint has to send the secrets again.
- fix: security: secrets no longer reach the logs or the Test notification response. A failed send no longer shows its request URL, which holds the bot token for Telegram, and monitor configs and queue payloads are no longer logged (#52)
- fix: security: a badge's color options take only hex colors and shields.io color names, so a crafted badge URL can't add markup to the SVG. Any other value draws the default color. `color=blue` on status, uptime and certificate expiry badges now draws blue (#49)
- fix: security: a malformed socket.io message from a signed-in client no longer crashes the API (#51)
- fix: security: the API closes slow clients (10 seconds for the request headers, 30 for the whole request, 120 for an idle connection) and answers 413 to a request body over 1 MiB (#53)
- fix: in cluster mode each notification is sent once, by one api replica. Before, every replica sent it, so three replicas sent three copies (#29)
- fix: a Down monitor stays Down until a check comes back Up. Before, it could slip back to Pending (a Docker container restarting, or max retries raised while Down), and then its Up went unannounced or its Down was sent twice (#133)
- fix: the resend interval counts only Down heartbeats of a Down monitor, so a Pending monitor no longer resends a Down notification it never sent (#132)
- fix: a monitor that is Down when its maintenance window ends sends a Down notification, and one that comes back Up after a window gets an event in its history (PP-131)
- fix: switched-off notification channels no longer send notifications (#418)
- fix: a probe's offline notification opens one incident on PagerDuty, Opsgenie, PagerTree and Grafana OnCall, keyed `Peekaping/<probe id>`, and its online notification closes it as the channel's auto-resolve setting says. The help under the auto-resolve settings of PagerDuty and PagerTree says so. Before, Opsgenie sent it as a test alert, PagerTree and PagerDuty opened a new one on each send, and Grafana OnCall titled it "General notification" (PP-274, PP-280, PP-282, PP-285)
  - Upgrade note: webhooks and custom templates see `monitor.status: 1` on a probe's online notification, where it was always `0`.
- fix: a probe's notifications link to the Probes page, not to a monitor page that doesn't exist, and Slack's rich messages for them get a button. The webhook payload carries `monitor.type: "probe"` for them (#259)
- fix: an HTTP check runs with its own monitor's timeout, TLS, proxy, mTLS and NTLM settings. Before, checks running at once could share one client and report another monitor's result (#57)
- fix: an OAuth2 client credentials check with "Authorization Header" sends the client ID and secret only in the header, so strict servers such as Authelia accept it (#276) (thanks @slopgobblercobbler)
- fix: HTTP monitors send `Content-Type` only with a request body, unless you set it in the headers. Some servers answered 400 to a bodyless GET with `Content-Type: application/json` (#415)
- fix: TCP, DNS, MQTT, PostgreSQL and proxy addresses, and SMTP notification channels, work with IPv6 hosts. Before, every check failed with "too many colons in address" (#128)
- fix: MongoDB, SQL Server and Kafka checks go Down within the monitor's timeout when the server never answers. Before, MongoDB took 30 seconds, Kafka about two minutes, and SQL Server hung. Kafka SCRAM-SHA-256 and SCRAM-SHA-512 work, and SQL Server passwords with `;`, `=`, `{` or `}` are passed right (PP-291, PP-294, PP-327)
- fix: creating or updating a monitor saves the monitor and its notification channel and tag links together, or nothing. Before, a failed link left the monitor saved and scheduled, and a monitor's first notification could find no channels (#86)
- fix: the api shuts down cleanly on SIGTERM within 30 seconds: it finishes the requests in flight, then closes the websocket, the jobs, the event bus and the database. The cluster compose files and the Helm chart give it 35 seconds to stop (#84)
- fix: `bun db` and the migrate image carry the SQL migrations in the binary, so they run without the source tree (#230)
- fix: a period with no heartbeats shows a dash with a "No data" tooltip on the monitor page, the chart and status pages, and badges say "N/A" in grey, instead of 0% uptime. The uptime API answers `null` for it (PP-344)
  - Upgrade note: API clients that read `stats/uptime` or a status page's `uptime_24h` as a number have to handle `null`.
- fix: the response time chart of a monitor checked less often than once a minute keeps its buckets in place between refreshes, and the first point of the 24-hour and 1-week charts is no longer empty (PP-345)
- fix: in single mode the Probes page shows the local probe online (PP-325)
- fix: the heartbeat bar fits its bars again when the sidebar opens or closes (#267)
- fix: an empty password field shows the "Password is required" message in every language. Its translation key was missing (#357) (thanks @larswmh)

## [0.0.47] - 2026-10-19

### Added

- feat: the API serves Prometheus metrics at `/metrics` on its own port when `METRICS_ENABLED=true` (off by default). Each active monitor has `peekaping_monitor_status` (0 Down, 1 Up, 2 Pending, 3 Maintenance), `peekaping_monitor_last_heartbeat_timestamp_seconds`, and `peekaping_monitor_response_seconds` while it is Up, labelled with the monitor's ID, name and type; a paused or deleted monitor has none. The endpoint needs no credentials and lists monitor names, so scrape it over a private network; the gateway doesn't forward it. See [Prometheus Metrics](apps/docs/docs/architecture/api-server.md#prometheus-metrics) (#267, #277, #350) (thanks @SiteRelEnby)
- feat: SMTP monitor type. It connects over plain SMTP, STARTTLS or direct TLS, can log in with PLAIN authentication, can test whether the server is an open relay, and tracks the TLS certificate's expiry (#250) (thanks @czimnick)
- feat: Microsoft Teams notification channel. Point it at a Workflows webhook to get alerts with the monitor's status, details and links (#245) (thanks @mtb77)
- feat: a Slack notification channel can post with a bot token and a channel name instead of a webhook URL. The webhook URL is optional now: a channel needs a webhook URL or a bot token, and a bot token needs a channel (#248)
- feat: a webhook notification channel can sign its requests. With a signing secret set, each request carries `X-Webhook-Timestamp` (Unix seconds) and `X-Webhook-Signature` (`sha256=` and the hex HMAC-SHA256 of `{timestamp}.{payload}`); without one, requests go out unsigned as before. Signed requests don't follow redirects. See [Webhook](apps/docs/docs/notifications/webhook.md) for how to verify the signature (#288) (thanks @jonfriesen)
- feat: `REDIS_USERNAME` sets the Redis ACL user for the API, producer, worker and ingester, for a shared Redis 6 or later where each application has its own user. Leave it empty to keep password-only authentication (#279, #280) (thanks @daftu)
- feat: a stale-monitor watchdog. Every minute the leader producer finds active monitors that haven't been checked for longer than 3 check intervals (or interval + timeout + 60 seconds, if longer), logs a warning and queues a check. If a monitor is still stale once that check has had time to finish, its notification channels get one message, and another when it is checked again. The monitor list and page show "Not checked since <time>", and `GET /api/v1/health` reports `stale_monitors` and `watchdog_ran_at` (#321, #322)

### Changed

- feat: new installs keep heartbeats for 180 days instead of 365. An install counts as new when the API starts with no admin and no heartbeats yet. Upgrading changes nothing: an install that never saved a data retention period keeps 365 days, and a saved period stays as it is. Charts and uptime % come from stats, so only the recent heartbeats list and a monitor's event history reach back less far on new installs (#337)
- refactor: SQL databases write a stats row with one `INSERT ... ON CONFLICT` statement instead of an UPDATE followed by an INSERT. What is stored doesn't change (#260, #311) (thanks @brewle)
- feat: Grafana OnCall alerts also carry the heartbeat's `status`, `ping_ms`, `retries` and `time`, and the monitor's `monitor_id`, `monitor_type` and `tags`, so OnCall can route and group them. HTTP monitors add `monitor_url` (without credentials, query or fragment); TCP, ping, DNS and gRPC keyword monitors add `monitor_host`; other types send no target (#263) (thanks @brewle)
- feat: the bundle images run the API, producer, worker and ingester as a non-root user, `peekaping` (UID/GID 1000); `PUID` and `PGID` change the IDs. Caddy, Redis, the database and supervisord keep their users. Upgrade note: on start, `/app/data` is chowned to that user, which changes the owner of files an older image wrote as root; where root can't change owners, such as NFS with `root_squash`, startup warns and carries on. A Docker socket at `/var/run/docker.sock` keeps working, and ping monitors need `net.ipv4.ping_group_range` to include the user's groups (Docker allows it by default; startup warns where it doesn't). See [Single-mode user and permissions](apps/docs/docs/self-hosting/single-user.md) (#163, #209, #316) (thanks @tafaust)
- refactor: the API server runs on Go's standard HTTP router instead of Gin, and its 75 operations are typed Huma operations instead of Swag annotations. Paths, response formats, authentication and validation stay the same. The API reference is now OpenAPI 3.1 instead of Swagger 2, served as an interactive page at `/docs`; `/swagger/index.html` redirects there and `/swagger/doc.json` stays as an alias (#329, #332)

### Fixed

- fix: when the web app talks to the API on another URL (`API_URL`), a page load sends each API request once, to the API. Before, the first requests (the version check and the custom-domain lookup) went to the web app itself, failed, and ran again against the API (PP-210)
- fix: a data retention period of 0, a negative number or a very large number no longer makes the hourly cleanup delete every heartbeat. The settings API returns 400 unless `KEEP_DATA_PERIOD_DAYS` is a whole number of days from 1 to 36,500 with type `int`. If an invalid value was stored before, or the setting can't be read, the cleanup logs an error and deletes no heartbeats instead of falling back to 365 days (#337)
- fix: the hourly cleanup deletes expired heartbeats 5,000 at a time on SQL databases instead of in one statement, so a large backlog, such as the first run after lowering the data retention period, no longer blocks the API. On SQLite, deleting a million heartbeats used to hold the database for about 13 seconds; now no request waits more than about half a second. A migration replaces three heartbeat indexes that no query uses with one on the heartbeat time, which the cleanup deletes through. Each heartbeat gets about 15% smaller on SQLite, and loading a monitor's event history gets faster. The migration builds the new index, about 1 second per million heartbeats; on PostgreSQL new heartbeats wait while it does. On SQLite the database file stops growing but doesn't shrink on its own (#336)
- fix: a network reconnect (a laptop waking, say) no longer wipes a half-filled form. The app re-checks the custom-domain lookup in the background and keeps the current screen mounted, instead of unmounting every route for the length of the refetch (#161, #228, PP-196)
- fix: minute and hour stats have bounded retention. Every hour, minute stats older than 48 hours and hour stats older than 90 days are deleted; day stats are kept indefinitely and add about 365 rows per monitor each year. Uptime % and badges read minute stats for up to 24 hours and hour stats for up to 90 days, and charts read less. On SQL databases the rows are deleted 5,000 at a time, so the first run after upgrading clears a year of backlog without blocking the API. SQLite reuses freed pages, but its file does not shrink automatically (#315)
- docs: explain SQL stats upgrade history limits, backups and rollback, and provide a manual SQLite disk-space reclamation procedure. The migration drops the mixed legacy stats table and rebuilds history only from retained heartbeats; take a database backup before upgrading. See [Upgrading stats storage](apps/docs/docs/self-hosting/upgrading-stats.md) (PP-101)
- fix: heartbeats for the same monitor that the ingester handles at the same time no longer overwrite each other's counts in the stats behind the response time chart and uptime %, and no longer fail with a unique-key error. On MongoDB they no longer create a second stats document for the same minute, hour or day. Each heartbeat now updates its stats with 3 statements instead of 6 to 9 (#312)
- fix: the ingester logs an error with the monitor ID when it can't aggregate a heartbeat into the stats behind the response time chart and uptime %, instead of dropping the error (#260)
- fix: the API, producer, worker and ingester binaries embed the time zone database, so `TZ=Europe/Kyiv` and maintenance windows with their own time zone work in any image instead of falling back to UTC, and a time zone that fails to load logs a warning. "Same as Server Timezone" is the zone the process runs in. Upgrade note: the separate API, producer, worker and ingester images (Docker Compose, Helm) used to ignore `TZ` and run in UTC; after upgrading they run in the `TZ` you set (the example `.env` files set `America/New_York`), so "Same as Server Timezone" windows, log timestamps and times in notification messages move to that zone. Set `TZ=UTC` to keep the old behavior. The bundle images already honored `TZ` (#261, #262) (thanks @brewle)
- fix: the monitor page header shows the target of TCP, DNS, ping, Docker, gRPC, SNMP, MQTT, Kafka and RabbitMQ monitors, such as `1.1.1.1:443` or `example.com (A @ 8.8.8.8:53)`, instead of only the monitor type. Monitors whose target holds credentials or a token (database, Redis and push monitors) still show only the type. Only `http(s)` URLs are links (#269, #270, #308) (thanks @HartD92)
- fix: a status page that isn't published returns 404 on its public slug, custom domain and monitor routes, instead of serving its title and monitors. The View button on a draft status page therefore shows a 404 until the page is published (#305)
- fix: badges are served only for monitors on a published status page. A monitor that is only on a draft page gets no badge, and a failed status page lookup returns 500 instead of serving the badge (#305)
- fix: updating, pausing, resuming or deleting a monitor, maintenance window, notification channel, proxy, tag, status page or API key that doesn't exist returns 404 instead of reporting success, and so do the heartbeat, stats and TLS routes of a monitor that doesn't exist (#305)
- fix: the certificate expiry badge and the monitor TLS endpoint no longer fail with 500 on SQL databases for a monitor without certificate info, and the response time badge shows "N/A" instead of "N/Ams" before the first heartbeat (#305)
- fix: invalid updates return 400: a proxy's port or protocol in a partial update, a monitor's config in a partial update (checked against its type), and a maintenance window's partial update. A maintenance window's start and end time must be a valid HH:MM time; they weren't checked before (#305)
- fix: the settings API serves only `KEEP_DATA_PERIOD_DAYS` and returns 404 for every other key, so callers can no longer read, change or delete the JWT signing secrets. Upgrading generates new signing secrets, which signs everyone out once. With more than one API replica, old and new pods reject each other's tokens until the rolling update finishes, so users can be signed out again during it; to avoid that, run `kubectl scale deployment <release>-api --replicas=0` before `helm upgrade`, which scales it back up (#302)
- fix: reject page sizes above 1,000 on paginated list endpoints (#295) (thanks @0xfurai)
- fix: monitors no longer stop checking after a check times out; existing stuck monitors recover on upgrade (#237, #254, #265, #266, #294)
- fix: editing, pausing or deleting a monitor drops its queued check and cancels a running one, so no result of the old config is recorded and the next check uses the current config, right away after an edit (#266)
- fix: close idle HTTP connections after each check, so HTTP monitors no longer leak one socket per check (#287) (thanks @jonfriesen)
- fix: ping monitors report an unreachable host as DOWN instead of "ping: executable file not found", no longer pick up other monitors' replies, work without root where unprivileged ICMP is allowed, and support IPv6 targets (#226, #297)
- fix: editing a monitor, or creating one, no longer overwrites its stored status. The monitor list's status filter and the status badge read that stored status, so an edited Down monitor was listed under Up and its badge said Up. A new monitor starts as Pending, and on start the API repairs monitors whose stored status differs from their latest heartbeat's (#285, #352, #353)
- fix: a push monitor's heartbeat is recorded as Maintenance, with the maintenance message, while the monitor is in a maintenance window, instead of the status the push reported. If the maintenance check fails, the push's own status is used (#349) (thanks @dajiaohuang)
- fix: a push URL with `status=down` is recorded as Down, and its `ping` as the response time. Before, `down` failed to parse and counted as Up, and `ping` was ignored (#298) (thanks @sean0407)
- fix: a gRPC monitor whose target can't be reached is Down. Before, any failure produced a made-up `SERVING` response, so it showed Up; the default method `check` also didn't match the proto's `Check`. A DNS monitor stops at its timeout instead of waiting 10 to 20 seconds on a resolver that never answers (#303)
- fix: on SQLite, a cancelled request no longer makes every API write fail with `database is locked (517)` until the API restarts. The SQLite driver is upgraded to a version that closes the cancelled query's rows (#346)
- fix: the monitor list card showed Pending and Maintenance monitors as Up, and the monitor page showed Pending as Down. Both show Up, Down, Pending or Maintenance by name (#344)
- fix: after the socket reconnects, the monitor list keeps updating live instead of freezing until the page is reloaded (#343)
- fix: a certificate expiry notice goes out once per threshold, such as 21, 14 and 7 days, instead of stopping after the first. A certificate first seen inside several thresholds gets only the nearest one, not all at once (#324)
- fix: a recurring-interval maintenance window counts calendar days in its time zone. Before, it was active on the wrong days around midnight and daylight saving changes, so alerts fired during planned maintenance and were muted outside it. A single date-range window now includes its first minute (#323)
- fix: failed logins lock an account on SQLite and MongoDB, as they do on PostgreSQL. The lock never applied there before. After `BRUTEFORCE_MAX_ATTEMPTS` failures (20 by default) within `BRUTEFORCE_WINDOW` (1 minute by default), the account is locked for `BRUTEFORCE_LOCKOUT` (1 minute by default). On every database, `BRUTEFORCE_MAX_ATTEMPTS=1` now locks after one failure instead of two (#313)
- fix: setting up 2FA a second time before verifying the first showed a QR code whose codes never verified, and a failed save of the 2FA status still answered "2FA verification successful" (#314)
- fix: the production Docker Compose files and the self-hosting guides give `web` and `gateway` `restart: unless-stopped`, so the nginx gateway no longer stays stopped after a Docker restart. Add the two lines to a compose file you copied earlier (#33, #331)
- fix: the Telegram channel's template hint names `{{ monitor.name }}` instead of a variable that doesn't exist (#253, #281) (thanks @dimonl)
- fix: uptime % on the monitor page (24h, 7d, 30d and 365d) and the 24h, 30d and 90d badges count the right window. They read day stats, which count only when the day starts inside the window, so "24h" meant since midnight UTC and 7d and 30d dropped everything before the first midnight (#320)
- fix: SQL databases keep minute, hour and day stats in their own tables (`stat_minutely`, `stat_hourly`, `stat_daily`). Before, one table mixed the three scales, so the 14:00 minute row also held the whole 14:00 hour, and maintenance checks lowered the average response time. Upgrade note: a migration creates the tables, rebuilds them from the heartbeats you still have and drops the old `stats` table; take a backup first, as the docs entry above says (#306)

## [0.0.46] - 2026-04-10

### Added

### Changed

### Fixed

## [0.0.45] - 2025-12-21

### Added

### Changed

### Fixed

## [0.0.44] - 2025-10-29

### ⚠️ Breaking Changes
- Major architectural overhaul to make the system highly available and horizontally scalable (#207) (thanks @0xfurai)
- If you are running a microservices setup with Docker Compose, make sure to update your configuration — the app has now been split into multiple microservices, also update .env file from example
- The bundled version should continue to work as before without any changes

### Added

### Changed

### Fixed

## [0.0.42] - 2025-10-14

### Added

### Changed
> [!WARNING]
>Attention: Breaking change with the API Key !
>Change header from Authorization: pk_... to X-API-Key: pk_...

- feat!: refactor swagger API authentication, using X-API-Key header instead of Authorization for api key auth (#210) (Thanks @tafaust) 4b3adfa

- feat: automate version updates in release workflow (Thanks @0xfurai) 12cb83e

### Fixed

## [0.0.41] - 2025-10-11

### Added

- Feature: add api key auth (#204) (Thanks @tafaust) 0ad9871
- docs(README.md): add community terraform provider mention (#205) (Thanks @mail) 4c6a127

### Changed

- chore: update Makefile and add asdf configuration for Go environment (#202) (Thanks @tafaust) 1336d6c
- Update documentation headers for badges and intro pages by removing emojis for consistency (Thanks @0xfurai) e934fda

### Fixed

- fix(docs): update healthcheck command in Docker configurations to use wget with output option (#200) (Thanks @Shurco) a523953
- Fix/monitor partial update not update config (#194) (Thanks @0xfurai) a01404b
- Fix/update monitor 200 if monitor not found (#193) (Thanks @0xfurai) 3fab720
- refactor(monitor): update buildSetMapFromModel function to preserve created_at timestamp during updates (#192) (Thanks @0xfurai) a77456f
- fix(forms): preserve current name when resetting form in create mode across multiple components (#191) (Thanks @0xfurai) d0cc2ca

## [0.0.40] - 2025-09-25

### Added
- mask password field with toggle option for http monitor authentication block

### Changed

### Fixed
- update default data retention period to 365 days in ui and add settings navigation option

## [0.0.39] - 2025-09-08

### Added
- Chinese translation (thanks @MciG-ggg)
- General translations support (thanks @0xfurai)
-  LINE messaging channel for notifications (thanks @KarinaOliinyk)
- Keyword and JSON-over-HTTP support (thanks @0xfurai)

### Changed
- Domains are now unique across all status pages (thanks @sergeykobylchenko)

### Fixed
- Heartbeat retry interval labels now correctly show actual form values instead of the hardcoded 60/48 seconds (thanks @JustAnotherDevGuy)

## [0.0.38] - 2025-08-29

### Added
- feat: badges (#156) (Thanks @0xfurai)
- feat: enhance Playwright configuration and improve test coverage (Thanks @0xfurai)
- feat: pagertree (#152) (Thanks @KarinaOliinyk)
- feat: implement pushbullet notifications (#147) (Thanks @KarinaOliinyk)
- feat: add password visibility toggle to login form (#146) (Thanks @KarinaOliinyk and @AbhishekG-Codes)
- feat: improve landing page (Thanks @0xfurai)

### Changed
- refactor: remove unused proxy handling from Push Monitor (Thanks @0xfurai)
- refactor: remove unused Card components from Push Monitor (Thanks @0xfurai)

### Fixed

## [0.0.37] - 2025-08-18

### Added
- add twilio to notification chanel

### Changed

### Fixed
- add the server name in message for pushover
- fix broken FindAll with tags

- Fix custom domain issues

## [0.0.36] - 2025-07-28

### Added

- implement rendering certificate information for https monitors

### Changed

- enhance changelog generation script to extract GitHub usernames

### Fixed

## [0.0.35] - 2025-07-27

### Added

- Add ability to set custom domain for status pages
- Add ability to check certificate expiration and notify about it

### Changed

- Change api url for web client in dev mode - now it is proxied via Vite

### Fixed

- Fix push monitor url