Compare commits

5 Commits
Author SHA1 Message Date
VeillaxandClaude Sonnet 5.5 b4fd31a182 Add website analytics
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a
page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages,
referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored
IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:55 -06:00
VeillaxandClaude Sonnet 5.5 4ea4fe5289 Show 90-day uptime bars on the status page
Every check is also counted in a per-day table that outlives the raw checks, so the public page
can draw one bar per UTC day with a tooltip, as most hosted status pages do. Existing databases
are backfilled once from the checks they still have.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:54 -06:00
VeillaxandClaude Sonnet 5.5 73f45fb3ae Add self-update, including in Docker
Sandbar can install new releases itself using Reef's selfupdate, automatically with
SANDBAR_AUTO_UPDATE=1 or from a button on the Account page. In a container the new binary is
written to <data>/bin and takes over from the image at start, until the image catches up.
Tags now publish a Gitea release with archives and SHA256SUMS as well as the image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:54 -06:00
VeillaxandClaude Sonnet 5.5 955e01333d Make the topic feeds work with the ntfy apps
The JSON stream now carries the event field the apps look for, sends open and keepalive
events, and supports ?poll=1 and ?since= with a duration or a message id, which is how the
apps catch up. Adds a walkthrough for the phone app to the README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:53 -06:00
Veillax 7994ad492c more stuff
CI / test (push) Successful in 1m16s
Release / image (push) Successful in 3m42s
2026-10-27 03:41:35 -06:00
25 changed files with 1852 additions and 55 deletions

No files matched your search

+12
View File
@@ -23,9 +23,21 @@ SANDBAR_MASTER_KEY=
# Leave empty and /metrics does not exist. # Leave empty and /metrics does not exist.
#SANDBAR_METRICS_TOKEN= #SANDBAR_METRICS_TOKEN=
# Install new releases by itself: checks every few hours, then downloads, verifies and restarts.
# Works in Docker too (the new binary is kept in <data>/bin). Off by default.
#SANDBAR_AUTO_UPDATE=1
# Where releases come from, if not this project on git.veillax.com.
#SANDBAR_UPDATE_SERVER=https://git.veillax.com
#SANDBAR_UPDATE_REPO=Veillax/sandbar
# Behind a reverse proxy, take the client address from X-Forwarded-For. Needed for website analytics and
# the sign-in limit to tell visitors apart. Only set it when the proxy is the only way to reach Sandbar.
#SANDBAR_TRUST_PROXY=1
# How long topic messages and monitor results are kept, in days. # How long topic messages and monitor results are kept, in days.
#SANDBAR_MESSAGE_DAYS=30 #SANDBAR_MESSAGE_DAYS=30
#SANDBAR_CHECK_DAYS=30 #SANDBAR_CHECK_DAYS=30
#SANDBAR_ANALYTICS_DAYS=365
# Outgoing email, for email channels and for paging people by email. # Outgoing email, for email channels and for paging people by email.
#SANDBAR_SMTP_HOST=smtp.example.com #SANDBAR_SMTP_HOST=smtp.example.com
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+4
View File
@@ -31,6 +31,10 @@ jobs:
exit 1 exit 1
fi fi
- name: Shell scripts parse
run: |
for f in deploy/*.sh; do sh -n "$f"; done
- name: Vet - name: Vet
run: go vet ./... run: go vet ./...
+21 -14
View File
@@ -1,18 +1,22 @@
name: Release name: Release
# Push a tag such as v0.1.0 to build and publish the container image: # Push a tag such as v0.2.0 to build and publish a release:
# git tag -a v0.1.0 -m "sandbar v0.1.0" && git push origin v0.1.0 # git tag -a v0.2.0 -m "sandbar v0.2.0" && git push origin v0.2.0
# The release carries the archives and SHA256SUMS that self-update downloads, and the
# container image is pushed to the registry on this server.
on: on:
push: push:
tags: ['v*'] tags: ['v*']
jobs: jobs:
image: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
GOPRIVATE: git.veillax.com GOPRIVATE: git.veillax.com
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0 # release notes come from the history since the previous tag
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
@@ -28,19 +32,22 @@ jobs:
go vet ./... go vet ./...
go test ./... go test ./...
# The Dockerfile packages these binaries, so the arm64 image needs no emulation. - name: Build release archives
- name: Build the binaries run: sh deploy/build.sh "${GITHUB_REF_NAME}"
run: |
for a in amd64 arm64; do
mkdir -p dist/bin/linux_$a
CGO_ENABLED=0 GOOS=linux GOARCH=$a go build -trimpath \
-ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \
-o dist/bin/linux_$a/sandbar ./cmd/sandbar
done
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token with # Optional secret RELEASE_TOKEN: an access token that can write releases, for
# package write permission, for servers where the job token can't push. # servers where the automatic job token can't.
- name: Publish the release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
run: sh deploy/publish-release.sh "${GITHUB_REF_NAME}"
# Skipped when the repository variable PUBLISH_DOCKER is "false", or fails alone
# if this runner has no Docker: the release above is already published by then.
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token
# with package write permission, for servers where the job token can't push.
- name: Build and push the container image - name: Build and push the container image
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }} REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
+1
View File
@@ -5,3 +5,4 @@
master.key master.key
.devdata/ .devdata/
bin/ bin/
dist/
+154 -3
View File
@@ -3,12 +3,13 @@
Self-hosted notifications, monitoring, on-call and incident response in one small program. Self-hosted notifications, monitoring, on-call and incident response in one small program.
One binary, one SQLite file, and everything is done in the web UI. One binary, one SQLite file, and everything is done in the web UI.
Sandbar covers the ground of four tools: Sandbar covers the ground of five tools:
| Area | Like | What it does | | Area | Like | What it does |
|---|---|---| |---|---|---|
| **Topics** | ntfy | Publish a message to a topic with one HTTP request. Subscribe in the browser, with `curl`, or with the ntfy phone app | | **Topics** | ntfy | Publish a message to a topic with one HTTP request. Subscribe in the browser, with `curl`, or with the ntfy phone app |
| **Monitors** | Tianji, Uptime Kuma | Website, port, DNS and certificate checks, heartbeats for cron jobs, and a public status page | | **Monitors** | Tianji, Uptime Kuma | Website, port, DNS and certificate checks, heartbeats for cron jobs, and a public status page |
| **Websites** | Tianji, Plausible, Umami | Page views, visitors, referrers, browsers, countries and custom events for your own sites, with no cookies and no personal data |
| **On-call** | Opsgenie | Rotating schedules, escalation policies, and alerts that are acknowledged, snoozed or resolved | | **On-call** | Opsgenie | Rotating schedules, escalation policies, and alerts that are acknowledged, snoozed or resolved |
| **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem | | **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem |
@@ -29,7 +30,107 @@ bin/sandbar -listen :8090 -url https://sandbar.example.com
Open `http://localhost:8090` and create the first administrator. Or set `SANDBAR_ADMIN_EMAIL` and Open `http://localhost:8090` and create the first administrator. Or set `SANDBAR_ADMIN_EMAIL` and
`SANDBAR_ADMIN_PASSWORD` in `.env` to create it at startup. `SANDBAR_ADMIN_PASSWORD` in `.env` to create it at startup.
Docker: `docker run -p 8090:8090 -v sandbar-data:/data -e SANDBAR_MASTER_KEY=... sandbar`. ## Deploying
### Docker
Pushing a version tag publishes a multi-architecture (amd64 and arm64) image to the registry on the same server as
the repository, as `git.veillax.com/veillax/sandbar:<version>` and `:latest`. Generate a master key once with
`docker run --rm git.veillax.com/veillax/sandbar -genkey`, then keep it in a `.env` file next to this compose file:
```yaml
services:
sandbar:
image: git.veillax.com/veillax/sandbar:latest
restart: unless-stopped
ports:
- "127.0.0.1:8090:8090"
env_file: .env
environment:
SANDBAR_URL: https://sandbar.example.com
SANDBAR_AUTO_UPDATE: "1" # optional, see Updating below
volumes:
- sandbar-data:/data
volumes:
sandbar-data:
```
```
docker compose up -d
```
- The container runs as user 65532 and keeps its database in `/data`. Use a named volume as above. If you bind-mount
a folder instead, make it writable by 65532 (`chown 65532:65532 ./data`).
- Set `SANDBAR_MASTER_KEY` yourself. Without it a key is generated inside `/data`, next to the database it protects.
- To build the image locally, see the comment at the top of `Dockerfile`.
- Update by hand with `docker compose pull && docker compose up -d`, or let Sandbar do it (see Updating below).
Your data stays in the volume. Copy the data folder before upgrading, since there is no migration tooling yet.
### Binary
Download an archive from the release page (Linux amd64 and arm64, Windows amd64) or build it (`go build -o bin/ ./cmd/sandbar`), copy it to the server with a `.env` file, and run it
under a service manager. A minimal systemd unit:
```ini
[Unit]
Description=Sandbar
After=network-online.target
[Service]
User=sandbar
WorkingDirectory=/var/lib/sandbar
ExecStart=/usr/local/bin/sandbar -listen 127.0.0.1:8090 -env /etc/sandbar/.env
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Put `SANDBAR_DATA=/var/lib/sandbar` in the env file and make `/etc/sandbar/.env` readable only by the `sandbar` user.
### Updating
Pushing a version tag publishes a release with archives and `SHA256SUMS` as well as the image. Sandbar can install
new releases itself, in Docker and as a plain binary, on Linux and macOS.
- **Automatic:** set `SANDBAR_AUTO_UPDATE=1` (or pass `-auto-update`). Sandbar checks a few minutes after start and
then every few hours, downloads the archive for its platform, checks it against `SHA256SUMS`, checks that the new
binary runs, replaces itself and restarts. Pre-releases (`v1.0.0-rc1`) are never installed.
- **On demand:** an administrator can see the newest release and click **Update** on the Account page, with or
without automatic updates on.
- **In Docker:** the image is read-only to Sandbar, and a change inside the container would be lost when it is
recreated. So the new binary is written to `<data>/bin/sandbar` on the data volume. At every start Sandbar runs
that file when it is a newer release than the image, and deletes it once the image has caught up. The container
keeps running through the update (it re-executes in place, so the process id does not change), and
`docker compose pull` still works: a newer image takes over from the volume copy.
- **Pin a version:** leave automatic updates off and use an image tag such as `:v0.2.0` instead of `:latest`.
- **Requirements:** the release page must be readable without signing in, so the repository has to be public. A
development build (`dev`) and Windows never update themselves. `SANDBAR_UPDATE_SERVER` and `SANDBAR_UPDATE_REPO`
point it at a different Gitea server or fork. If a bad update leaves Sandbar unable to start, delete
`<data>/bin/sandbar` (or set `SANDBAR_NO_OVERLAY=1`) and it starts from the image again.
### HTTPS and a reverse proxy
Sandbar serves plain HTTP, so put a reverse proxy in front of it and set `SANDBAR_URL` to the public address, since
links in notifications use it. Also set `SANDBAR_TRUST_PROXY=1`, so website analytics and the sign-in limit see the
real client address instead of the proxy's. Caddy needs one line:
```
sandbar.example.com {
reverse_proxy 127.0.0.1:8090
}
```
With nginx, turn off response buffering for the live topic feeds (`/<topic>/sse` and `/<topic>/json`), or
subscribers see messages late: `proxy_buffering off; proxy_read_timeout 1h;`.
### After the first start
1. Open the address and create the administrator, or set `SANDBAR_ADMIN_EMAIL` and `SANDBAR_ADMIN_PASSWORD` first.
2. Add an email or webhook channel and use its test button, so you know alerts will reach you.
3. Create an API key on the Account page for scripts and monitoring agents.
4. Set up backups (see Database below), including the master key, which must be stored apart from the data.
## How the pieces fit ## How the pieces fit
@@ -59,6 +160,21 @@ A topic is 1 to 64 letters, digits, `-` or `_`. Publishing and subscribing both
API key (create one on the Account page). Clients that use basic auth can send the key as the password. API key (create one on the Account page). Clients that use basic auth can send the key as the password.
Messages are kept for `SANDBAR_MESSAGE_DAYS` (default 30). Messages are kept for `SANDBAR_MESSAGE_DAYS` (default 30).
`?since=` also takes unix seconds, a duration such as `10m`, or the id of the last message you have, and
`?poll=1` returns just that replay and closes, which is how the ntfy apps catch up.
### Phone app (ntfy)
1. Create an API key on the Account page and copy it.
2. In the ntfy app, open Settings, then Default server, and enter your Sandbar address
(`https://sandbar.example.com`). Or tick "Use another server" when you add each subscription.
3. Add a subscription. For a page when an escalation reaches you, use your personal topic from the Account page
(`u-<id>`). For everything, use `alerts` and `incidents`.
4. Under Manage users, add a user for the server. The username can be anything and the password is the API key.
The Android app keeps a connection open, so messages arrive at once. The iPhone app depends on ntfy.sh's push
relay, which Sandbar does not use, so there messages arrive only when the app polls.
Two topics are filled for you: `alerts` gets every alert and its recovery, and `incidents` gets every incident Two topics are filled for you: `alerts` gets every alert and its recovery, and `incidents` gets every incident
update. Each person also has a personal topic (`u-<id>`, shown on the Account page). Subscribe to it in the ntfy update. Each person also has a personal topic (`u-<id>`, shown on the Account page). Subscribe to it in the ntfy
app to be paged on your phone when an escalation policy reaches you. app to be paged on your phone when an escalation policy reaches you.
@@ -77,9 +193,44 @@ A monitor goes down after the set number of failures in a row (default 2), raise
escalation policy, and recovers on the first success. Monitors marked public appear on `/status`, together with escalation policy, and recovers on the first success. Monitors marked public appear on `/status`, together with
incidents marked public. The status page never shows staff names or private monitors. incidents marked public. The status page never shows staff names or private monitors.
The status page shows each public monitor as 90 daily uptime bars, like most hosted status pages. A day is green with
no failed checks, amber when at least 99% of checks passed, red below that, and grey with no data. Hovering a bar
(or tapping it on a phone) gives the date, the uptime and the number of failed checks. Days are UTC. Counts are kept
in a small per-day table, so the bars go back 90 days even though raw check results are kept for
`SANDBAR_CHECK_DAYS` only.
Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that
monitor, so a user who can add a monitor cannot use Sandbar to scan your network. monitor, so a user who can add a monitor cannot use Sandbar to scan your network.
## Websites (analytics)
Add a website on the Websites page, then paste its one-line tracking code into the head of your pages:
```html
<script defer src="https://sandbar.example.com/script.js" data-website="WEBSITE_ID"></script>
```
The page for each website shows visitors, page views, visits, bounce rate and who is online now, a chart over the last
24 hours, 7, 30 or 90 days, and top lists for pages, referrers, browsers, operating systems, devices, countries,
languages and events.
- **Single-page apps** are counted as people move between pages. Add `data-hash` to count `#hash` changes as pages,
or `data-auto="false"` to send nothing until your code calls `sandbar.view()`.
- **Events:** call `sandbar.track("signup", { plan: "pro" })` from your code.
- **Private by design:** no cookies, nothing stored in the browser, no IP address or user agent kept (only the labels
"Chrome", "macOS", "Mobile" and so on), and nothing is sent when the visitor has Do Not Track on. A visitor is a hash
of the address, the browser and the website with a secret and the date, so the same person is one visitor for a day
and cannot be followed beyond it. Query strings are dropped from paths and only the referrer's host is kept.
- **Domain:** a website's optional domain makes Sandbar ignore visits from other hosts. Bots, and anything sending
more than 120 hits a minute from one address, are ignored.
- **Countries** come from the `CF-IPCountry` or `CloudFront-Viewer-Country` header, so they appear only behind
Cloudflare or CloudFront. Sandbar bundles no location database. Without the header, visits count as Unknown.
- **Behind a reverse proxy,** set `SANDBAR_TRUST_PROXY=1` so the client address comes from `X-Forwarded-For`. Without
it every visitor looks like the proxy. Only set it when the proxy is the only way to reach Sandbar.
- Hits are kept for `SANDBAR_ANALYTICS_DAYS` (default 365).
Not included: funnels, goals, session replay, a public share link, and comparison with the previous period.
## On-call ## On-call
- A **schedule** rotates through people, handing over every N hours from a start time. **Overrides** cover a - A **schedule** rotates through people, handing over every N hours from a start time. **Overrides** cover a
@@ -136,7 +287,7 @@ than their retention are pruned hourly. Back up by copying the data folder while
- One instance, one organization. SQLite means no clustering. - One instance, one organization. SQLite means no clustering.
- No ICMP ping monitor, no SMS or voice, and no two-factor or single sign-on yet. - No ICMP ping monitor, no SMS or voice, and no two-factor or single sign-on yet.
- Sandbar serves plain HTTP. Put it behind HTTPS and set `-url`, or keep it on a private network. - Sandbar serves plain HTTP. Put it behind HTTPS and set `-url`, or keep it on a private network.
- Sign-in attempts are limited to 10 per 10 minutes per address. - Sign-in attempts are limited to 10 per 10 minutes per address (the client address, see `SANDBAR_TRUST_PROXY`).
- Tests: `go test ./...`. Style: `node scripts/lint-design.mjs` (see `docs/design.md`). - Tests: `go test ./...`. Style: `node scripts/lint-design.mjs` (see `docs/design.md`).
## License ## License
+29 -2
View File
@@ -18,6 +18,7 @@ import (
"git.veillax.com/Veillax/reef/dotenv" "git.veillax.com/Veillax/reef/dotenv"
"git.veillax.com/Veillax/reef/secretbox" "git.veillax.com/Veillax/reef/secretbox"
"git.veillax.com/Veillax/reef/selfupdate"
"git.veillax.com/Veillax/sandbar/internal/sandbar" "git.veillax.com/Veillax/sandbar/internal/sandbar"
) )
@@ -45,6 +46,7 @@ func main() {
data := flag.String("data", env("SANDBAR_DATA", "./sandbar-data"), "directory for the database") data := flag.String("data", env("SANDBAR_DATA", "./sandbar-data"), "directory for the database")
url := flag.String("url", env("SANDBAR_URL", ""), "public URL (used for links in notifications)") url := flag.String("url", env("SANDBAR_URL", ""), "public URL (used for links in notifications)")
flag.String("env", ".env", "file to read secrets and settings from") flag.String("env", ".env", "file to read secrets and settings from")
autoUpdate := flag.Bool("auto-update", os.Getenv("SANDBAR_AUTO_UPDATE") == "1", "check for new releases every few hours and update and restart by itself (Linux and macOS, including Docker)")
genkey := flag.Bool("genkey", false, "print a new random SANDBAR_MASTER_KEY and exit") genkey := flag.Bool("genkey", false, "print a new random SANDBAR_MASTER_KEY and exit")
showVersion := flag.Bool("version", false, "print the version and exit") showVersion := flag.Bool("version", false, "print the version and exit")
flag.Parse() flag.Parse()
@@ -58,6 +60,9 @@ func main() {
return return
} }
// In a container, an update lives on the data volume and takes over from the image.
sandbar.UseNewerOverlay(*data, version, log.Printf)
key, err := masterKey(*data) key, err := masterKey(*data)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -72,20 +77,42 @@ func main() {
} }
defer st.Close() defer st.Close()
var hs *http.Server
atoi := func(k string) int { n, _ := strconv.Atoi(os.Getenv(k)); return n } atoi := func(k string) int { n, _ := strconv.Atoi(os.Getenv(k)); return n }
srv := sandbar.New(sandbar.Options{ var srv *sandbar.Server
srv = sandbar.New(sandbar.Options{
DataDir: *data, DataDir: *data,
URL: strings.TrimRight(*url, "/"), URL: strings.TrimRight(*url, "/"),
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"), MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
MessageDays: atoi("SANDBAR_MESSAGE_DAYS"), MessageDays: atoi("SANDBAR_MESSAGE_DAYS"),
CheckDays: atoi("SANDBAR_CHECK_DAYS"), CheckDays: atoi("SANDBAR_CHECK_DAYS"),
HitDays: atoi("SANDBAR_ANALYTICS_DAYS"),
TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1",
Version: version,
AutoUpdate: *autoUpdate,
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
UpdateRepo: os.Getenv("SANDBAR_UPDATE_REPO"),
Restart: func(exe string) error {
// Let go of the port and the database, then become the new binary. If that fails, exit
// so the container or service manager starts it again, now from the new file.
hs.Close()
srv.Close()
st.Close()
err := selfupdate.Restart(exe)
log.Printf("could not restart into %s, exiting: %v", exe, err)
os.Exit(1)
return err
},
SMTP: sandbar.SMTPConfig{Host: os.Getenv("SANDBAR_SMTP_HOST"), Port: os.Getenv("SANDBAR_SMTP_PORT"), SMTP: sandbar.SMTPConfig{Host: os.Getenv("SANDBAR_SMTP_HOST"), Port: os.Getenv("SANDBAR_SMTP_PORT"),
User: os.Getenv("SANDBAR_SMTP_USER"), Password: os.Getenv("SANDBAR_SMTP_PASSWORD"), From: env("SANDBAR_SMTP_FROM", "sandbar@localhost")}, User: os.Getenv("SANDBAR_SMTP_USER"), Password: os.Getenv("SANDBAR_SMTP_PASSWORD"), From: env("SANDBAR_SMTP_FROM", "sandbar@localhost")},
}, st) }, st)
srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD")) srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD"))
srv.Start() srv.Start()
if *autoUpdate {
go srv.RunAutoUpdate(context.Background())
}
hs := &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute} hs = &http.Server{Addr: *listen, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute}
go func() { go func() {
log.Printf("sandbar %s listening on %s", version, *listen) log.Printf("sandbar %s listening on %s", version, *listen)
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
# Builds sandbar for every release platform and packs the archives into dist/.
#
# deploy/build.sh [version] version defaults to `git describe`
#
# Output:
# dist/bin/<os>_<arch>/sandbar the raw binaries (the Docker image uses the linux ones)
# dist/sandbar_<version>_<os>_<arch>.tar.gz or .zip
# dist/SHA256SUMS
#
# Self-update downloads the linux tar.gz archives, so both linux architectures are always built.
# Set TARGETS="linux/arm64 darwin/arm64" (any GOOS/GOARCH pair Go supports) to build something else by hand.
set -eu
NAME=sandbar
cd "$(dirname "$0")/.."
VERSION=${1:-$(git describe --tags --always 2>/dev/null || echo dev)}
TARGETS=${TARGETS:-"linux/amd64 linux/arm64 windows/amd64"}
rm -rf dist
mkdir -p dist/bin dist/stage
for t in $TARGETS; do
os=${t%/*}
arch=${t#*/}
ext=""
[ "$os" = windows ] && ext=".exe"
out="dist/bin/${os}_${arch}"
mkdir -p "$out"
echo "building $NAME $VERSION for $os/$arch"
CGO_ENABLED=0 GOOS=$os GOARCH=$arch GOARM=7 \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$out/$NAME$ext" "./cmd/$NAME"
pkg="${NAME}_${VERSION}_${os}_${arch}"
stage="dist/stage/$pkg"
mkdir -p "$stage"
cp "$out/$NAME$ext" "$stage/"
for f in LICENSE README.md .env.example; do
[ -f "$f" ] && cp "$f" "$stage/"
done
if [ "$os" = windows ] && command -v zip >/dev/null 2>&1; then
(cd dist/stage && zip -qr "../$pkg.zip" "$pkg")
else
tar -C dist/stage -czf "dist/$pkg.tar.gz" "$pkg"
fi
done
rm -rf dist/stage
(cd dist && sha256sum $(ls | grep -E '\.(tar\.gz|zip)$') > SHA256SUMS)
echo "done: $(ls dist | grep -cE '\.(tar\.gz|zip)$') archives in dist/"
+71
View File
@@ -0,0 +1,71 @@
#!/bin/sh
# Creates (or updates) the Gitea release for a tag and uploads what deploy/build.sh made.
#
# TOKEN=<access token that can write releases> deploy/publish-release.sh v0.2.0
#
# In Gitea Actions GITHUB_SERVER_URL and GITHUB_REPOSITORY are already set. Safe to
# re-run: an existing release is reused and assets with the same name are replaced.
set -eu
TAG=${1:?usage: publish-release.sh <tag>}
: "${TOKEN:?set TOKEN to a Gitea access token that can write releases}"
SERVER=${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-https://git.veillax.com}}
REPO=${GITHUB_REPOSITORY:-Veillax/sandbar}
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# a JSON helper without needing jq: any python will do
PY=python3
python3 -c 'pass' > /dev/null 2>&1 || PY=python
json() { "$PY" -c "$1"; }
# release notes: the commit subjects since the previous tag
prev=$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || true)
if [ -n "$prev" ]; then
notes=$(git log --pretty='format:- %s' "$prev..$TAG")
else
notes=$(git log --pretty='format:- %s' "$TAG")
fi
prerelease=false
case "$TAG" in *-*) prerelease=true ;; esac
body=$(NOTES="$notes" TAG="$TAG" PRE="$prerelease" json '
import json, os
print(json.dumps({"tag_name": os.environ["TAG"], "name": os.environ["TAG"], "body": os.environ["NOTES"],
"draft": False, "prerelease": os.environ["PRE"] == "true"}))')
# reuse the release when a failed job is re-run
id=$(curl -fsS -H "$AUTH" "$API/releases/tags/$TAG" 2>/dev/null | json 'import sys, json; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
if [ -z "$id" ]; then
echo "creating release $TAG"
id=$(curl -fsS -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$body" "$API/releases" | json 'import sys, json; print(json.load(sys.stdin)["id"])')
else
echo "release $TAG already exists (id $id): replacing its files"
fi
# the ids of the release's assets with a given name (a failed upload can leave a partial one behind)
asset_ids() {
curl -fsS -H "$AUTH" "$API/releases/$id/assets" | "$PY" -c 'import sys, json
for a in json.load(sys.stdin):
if a["name"] == sys.argv[1]:
print(a["id"])' "$1"
}
for f in dist/*.tar.gz dist/*.zip dist/SHA256SUMS; do
[ -f "$f" ] || continue
name=$(basename "$f")
n=1
while :; do
for aid in $(asset_ids "$name"); do
curl -fsS -X DELETE -H "$AUTH" "$API/releases/$id/assets/$aid" > /dev/null
done
echo "uploading $name (attempt $n)"
if curl -fsS --max-time 1200 -X POST -H "$AUTH" -F "attachment=@$f" "$API/releases/$id/assets?name=$name" > /dev/null; then
break
fi
# large uploads can hit a proxy timeout (HTTP 502); try again
[ "$n" -lt 5 ] || { echo "giving up on $name after $n attempts" >&2; exit 1; }
n=$((n + 1))
sleep $((n * 10))
done
done
echo "published $SERVER/$REPO/releases/tag/$TAG"
+7 -1
View File
@@ -28,11 +28,17 @@ The helpers at the top of the script in `index.html` are the same as Hammerhead'
| `rowList(addLabel, make, initial)` | A list the person adds to and removes from, such as people in a rotation or steps in a policy | | `rowList(addLabel, make, initial)` | A list the person adds to and removes from, such as people in a rotation or steps in a policy |
| `timeline(events)` | Dated entries for an alert or an incident | | `timeline(events)` | Dated entries for an alert or an incident |
## Status page bars and charts
* **Uptime bars** on the public page are one bar per UTC day, 90 of them. Green is no failures, amber is at least 99% passing, red is below that, grey is no data. Color is never the only signal: the row carries a text status badge and the uptime percentage, each bar's tooltip says the date and the numbers in words, and the whole strip has an `aria-label` summary.
* **Analytics charts** are plain bars made of tokens, with two series (visitors in the accent, page views in a lighter mix of it), a legend with text, a native tooltip per bar, and an `aria-label` that gives the totals. There are no chart libraries.
* **Top lists** are tables with a proportional bar behind the name, so the numbers stay readable without the bar.
## Icons ## Icons
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`. Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
Approved names: `sandbar`, `alerts`, `incidents`, `oncall`, `monitors`, `topics`, `channels`, `status`, `users`, `account`, `out`, `menu`, `close`, `add`, `check`, `back`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together. Approved names: `sandbar`, `alerts`, `incidents`, `oncall`, `monitors`, `websites`, `topics`, `channels`, `status`, `users`, `account`, `out`, `menu`, `close`, `add`, `check`, `back`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
## Patterns ## Patterns
+431
View File
@@ -0,0 +1,431 @@
package sandbar
import (
"crypto/sha256"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
// Website analytics, in the manner of Tianji, Plausible and Umami: a small script on a site sends
// page views and custom events here, and the Websites page shows visitors, pages, referrers,
// browsers, systems, devices, countries and events.
//
// Privacy by construction: no cookies, no stored IP address or user agent. A visitor is a hash of the
// IP address, the user agent and the website, salted with a secret and the UTC date, so the same
// person is the same visitor within a day and cannot be followed beyond it.
//go:embed web/script.js
var trackerJS []byte
// Website is a site whose visits are counted.
type Website struct {
ID string `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"` // optional: when set, only hits from this host and its subdomains count
}
func prepareWebsite(v any, id string) error {
w := v.(*Website)
w.ID = id
w.Name = strings.TrimSpace(w.Name)
if w.Name == "" {
return errors.New("give the website a name")
}
d := strings.ToLower(strings.TrimSpace(w.Domain))
if d != "" {
if !strings.Contains(d, "//") {
d = "//" + d
}
u, err := url.Parse(d)
if err != nil || u.Hostname() == "" {
return errors.New("the domain should look like example.com")
}
d = u.Hostname()
}
w.Domain = d
return nil
}
func (s *Server) deleteWebsiteData(id string) {
s.st.db.Exec(`DELETE FROM hits WHERE website=?`, id)
}
// realIP is the address of the client. Behind a reverse proxy, set TrustProxy so the address the
// proxy added (the last entry of X-Forwarded-For) is used; otherwise every visitor looks like the proxy.
func (s *Server) realIP(r *http.Request) string {
if s.opts.TrustProxy {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
parts := strings.Split(xff, ",")
if ip := strings.TrimSpace(parts[len(parts)-1]); net.ParseIP(ip) != nil {
return ip
}
}
if ip := strings.TrimSpace(r.Header.Get("X-Real-IP")); net.ParseIP(ip) != nil {
return ip
}
}
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
func (s *Server) serveTracker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Write(trackerJS)
}
var botRe = regexp.MustCompile(`(?i)bot|crawl|spider|slurp|headless|lighthouse|preview|monitor|curl/|wget|python-requests|go-http-client|okhttp`)
// parseUA reduces a user agent to a browser, a system and a kind of device. Only these labels are stored.
func parseUA(ua string) (browser, os, device string) {
switch {
case strings.Contains(ua, "Edg/") || strings.Contains(ua, "EdgA/") || strings.Contains(ua, "EdgiOS/"):
browser = "Edge"
case strings.Contains(ua, "OPR/") || strings.Contains(ua, "Opera"):
browser = "Opera"
case strings.Contains(ua, "SamsungBrowser"):
browser = "Samsung Internet"
case strings.Contains(ua, "Firefox/") || strings.Contains(ua, "FxiOS/"):
browser = "Firefox"
case strings.Contains(ua, "Chrome/") || strings.Contains(ua, "CriOS/"):
browser = "Chrome"
case strings.Contains(ua, "Safari/"):
browser = "Safari"
default:
browser = "Other"
}
switch {
case strings.Contains(ua, "Windows"):
os = "Windows"
case strings.Contains(ua, "Android"):
os = "Android"
case strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad") || strings.Contains(ua, "iPod"):
os = "iOS"
case strings.Contains(ua, "Mac OS X") || strings.Contains(ua, "Macintosh"):
os = "macOS"
case strings.Contains(ua, "CrOS"):
os = "ChromeOS"
case strings.Contains(ua, "Linux") || strings.Contains(ua, "X11"):
os = "Linux"
default:
os = "Other"
}
switch {
case strings.Contains(ua, "iPad") || strings.Contains(ua, "Tablet") || (strings.Contains(ua, "Android") && !strings.Contains(ua, "Mobile")):
device = "Tablet"
case strings.Contains(ua, "Mobi") || strings.Contains(ua, "iPhone"):
device = "Mobile"
default:
device = "Desktop"
}
return
}
// secret is the random value that salts visitor hashes. It is made once and kept with the other settings.
func (s *Server) analyticsSecret() string {
s.anaMu.Lock()
defer s.anaMu.Unlock()
if s.anaSecret != "" {
return s.anaSecret
}
var v struct{ Secret string }
if s.st.get("setting", "analytics", &v) != nil || v.Secret == "" {
v.Secret = newToken()
s.st.put("setting", "analytics", &v)
}
s.anaSecret = v.Secret
return v.Secret
}
func (s *Server) visitorID(website, ip, ua string, now time.Time) string {
h := sha256.Sum256([]byte(s.analyticsSecret() + "|" + now.UTC().Format("2006-01-02") + "|" + website + "|" + ip + "|" + ua))
return hex.EncodeToString(h[:8])
}
// hitAllowed limits how fast one address can send hits: a script that loops must not fill the database.
func (s *Server) hitAllowed(ip string) bool {
now := s.now()
s.anaMu.Lock()
defer s.anaMu.Unlock()
if len(s.anaTry) > 20000 {
s.anaTry = map[string][]time.Time{}
}
recent := s.anaTry[ip][:0]
for _, t := range s.anaTry[ip] {
if now.Sub(t) < time.Minute {
recent = append(recent, t)
}
}
if len(recent) >= 120 {
s.anaTry[ip] = recent
return false
}
s.anaTry[ip] = append(recent, now)
return true
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) > n {
s = strings.ToValidUTF8(s[:n], "")
}
return s
}
// collect receives a page view or event from the tracker script. It is public by design, so it
// answers 204 to everything it ignores and gives nothing away.
func (s *Server) collect(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(204)
return
}
defer w.WriteHeader(204)
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4<<10))
if err != nil {
return
}
var in struct {
Website, Path, Referrer, Hostname, Language, Event string
Data map[string]any
}
if json.Unmarshal(body, &in) != nil {
return
}
var site Website
if s.st.get("website", in.Website, &site) != nil {
return
}
host := strings.ToLower(in.Hostname)
if site.Domain != "" && host != site.Domain && !strings.HasSuffix(host, "."+site.Domain) {
return
}
ua := r.UserAgent()
if ua == "" || botRe.MatchString(ua) {
return
}
ip := s.realIP(r)
if !s.hitAllowed(ip) {
return
}
path := in.Path
if i := strings.IndexAny(path, "?"); i >= 0 {
path = path[:i]
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
ref := ""
if u, err := url.Parse(in.Referrer); err == nil && u.Host != "" {
if rh := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www."); rh != strings.TrimPrefix(host, "www.") {
ref = rh
}
}
country := strings.ToUpper(clip(r.Header.Get("CF-IPCountry"), 2))
if country == "" {
country = strings.ToUpper(clip(r.Header.Get("CloudFront-Viewer-Country"), 2))
}
if country == "XX" || country == "T1" {
country = ""
}
lang := strings.ToLower(clip(in.Language, 12))
if i := strings.IndexAny(lang, "-_"); i > 0 {
lang = lang[:i]
}
data := ""
if in.Event != "" && len(in.Data) > 0 {
if b, err := json.Marshal(in.Data); err == nil && len(b) <= 1024 {
data = string(b)
}
}
browser, osName, device := parseUA(ua)
now := s.now()
s.st.db.Exec(`INSERT INTO hits(website,time,visitor,event,path,referrer,browser,os,device,country,lang,data) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`,
site.ID, now.Unix(), s.visitorID(site.ID, ip, ua, now), clip(in.Event, 80), clip(path, 200), ref, browser, osName, device, country, lang, data)
}
// ---------- reading ----------
type topRow struct {
Name string `json:"name"`
Visitors int `json:"visitors"`
Views int `json:"views"` // page views, or the number of times for an event
}
type seriesPoint struct {
Time int64 `json:"t"`
Views int `json:"views"`
Visitors int `json:"visitors"`
}
type siteStats struct {
Range string `json:"range"`
Bucket string `json:"bucket"` // "hour" or "day"
Visitors int `json:"visitors"`
Views int `json:"views"`
Visits int `json:"visits"`
BounceRate float64 `json:"bounceRate"` // percent of visits with one page view; -1 if no visits
Online int `json:"online"` // distinct visitors in the last 5 minutes
Series []seriesPoint `json:"series"`
Pages []topRow `json:"pages"`
Referrers []topRow `json:"referrers"`
Browsers []topRow `json:"browsers"`
Systems []topRow `json:"systems"`
Devices []topRow `json:"devices"`
Countries []topRow `json:"countries"`
Languages []topRow `json:"languages"`
Events []topRow `json:"events"`
}
var ranges = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour, "90d": 90 * 24 * time.Hour}
// visitGap is how long a visitor can be quiet before their next page view starts a new visit.
const visitGap = 30 * 60
func (s *Server) websiteStats(id, rng string) (*siteStats, error) {
span, ok := ranges[rng]
if !ok {
rng, span = "7d", ranges["7d"]
}
now := s.now()
from := now.Add(-span).Unix()
out := &siteStats{Range: rng, Bucket: "day", BounceRate: -1, Series: []seriesPoint{}}
step := int64(86400)
if rng == "24h" {
out.Bucket, step = "hour", 3600
}
db := s.st.db
db.QueryRow(`SELECT count(DISTINCT visitor), count(*) FROM hits WHERE website=? AND time>=? AND event=''`, id, from).Scan(&out.Visitors, &out.Views)
db.QueryRow(`SELECT count(DISTINCT visitor) FROM hits WHERE website=? AND time>=?`, id, now.Add(-5*time.Minute).Unix()).Scan(&out.Online)
// Visits and bounces: a visitor's page views, split wherever they were quiet for half an hour.
if rows, err := db.Query(`SELECT visitor,time FROM hits WHERE website=? AND time>=? AND event='' ORDER BY visitor,time`, id, from); err == nil {
var prev string
var prevT int64
views, bounces := 0, 0
closeVisit := func() {
if views > 0 {
out.Visits++
}
if views == 1 {
bounces++
}
}
for rows.Next() {
var v string
var t int64
if rows.Scan(&v, &t) != nil {
continue
}
if v != prev || t-prevT > visitGap {
closeVisit()
views = 0
}
views++
prev, prevT = v, t
}
closeVisit()
rows.Close()
if out.Visits > 0 {
out.BounceRate = float64(bounces) * 100 / float64(out.Visits)
}
}
// The chart: one point per hour or UTC day, empty ones included so the line has no gaps.
buckets := map[int64]seriesPoint{}
if rows, err := db.Query(`SELECT (time/?)*?, count(*), count(DISTINCT visitor) FROM hits WHERE website=? AND time>=? AND event='' GROUP BY 1`, step, step, id, from); err == nil {
for rows.Next() {
var p seriesPoint
if rows.Scan(&p.Time, &p.Views, &p.Visitors) == nil {
buckets[p.Time] = p
}
}
rows.Close()
}
for t := (from / step) * step; t <= now.Unix(); t += step {
p := buckets[t]
p.Time = t
out.Series = append(out.Series, p)
}
top := func(col string, events bool, empty string) []topRow {
cond := "event=''"
sel := col
if events {
cond, sel = "event!=''", "event"
}
rows, err := db.Query(`SELECT `+sel+`, count(DISTINCT visitor), count(*) FROM hits WHERE website=? AND time>=? AND `+cond+
` GROUP BY 1 ORDER BY 3 DESC, 1 LIMIT 10`, id, from)
list := []topRow{}
if err != nil {
return list
}
defer rows.Close()
for rows.Next() {
var r topRow
if rows.Scan(&r.Name, &r.Visitors, &r.Views) == nil {
if r.Name == "" {
r.Name = empty
}
list = append(list, r)
}
}
return list
}
out.Pages = top("path", false, "/")
out.Referrers = top("referrer", false, "Direct")
out.Browsers = top("browser", false, "Other")
out.Systems = top("os", false, "Other")
out.Devices = top("device", false, "Other")
out.Countries = top("country", false, "Unknown")
out.Languages = top("lang", false, "Unknown")
out.Events = top("", true, "")
sort.SliceStable(out.Series, func(i, j int) bool { return out.Series[i].Time < out.Series[j].Time })
return out, nil
}
func (s *Server) websiteStatsHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var site Website
if s.st.get("website", id, &site) != nil {
writeJSON(w, 404, map[string]string{"error": "No such website."})
return
}
st, _ := s.websiteStats(id, r.URL.Query().Get("range"))
writeJSON(w, 200, st)
}
// websiteSummary gives the list page a number for each site: visitors and page views in the last day.
func (s *Server) websiteSummary(w http.ResponseWriter, r *http.Request) {
out := map[string]map[string]int{}
since := s.now().Add(-24 * time.Hour).Unix()
rows, err := s.st.db.Query(`SELECT website, count(DISTINCT visitor), count(*) FROM hits WHERE time>=? AND event='' GROUP BY website`, since)
if err == nil {
defer rows.Close()
for rows.Next() {
var id string
var v, p int
if rows.Scan(&id, &v, &p) == nil {
out[id] = map[string]int{"visitors": v, "views": p}
}
}
}
writeJSON(w, 200, out)
}
+175
View File
@@ -0,0 +1,175 @@
package sandbar
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const (
uaChrome = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
uaPhone = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"
)
func TestParseUA(t *testing.T) {
for _, c := range []struct{ ua, browser, os, device string }{
{uaChrome, "Chrome", "Windows", "Desktop"},
{uaPhone, "Safari", "iOS", "Mobile"},
{"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", "Firefox", "Linux", "Desktop"},
{"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0", "Edge", "Windows", "Desktop"},
{"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/126.0 Mobile Safari/537.36", "Chrome", "Android", "Mobile"},
{"Mozilla/5.0 (Linux; Android 13; SM-X700) AppleWebKit/537.36 Chrome/126.0 Safari/537.36", "Chrome", "Android", "Tablet"},
} {
b, o, d := parseUA(c.ua)
if b != c.browser || o != c.os || d != c.device {
t.Errorf("%q: got %s/%s/%s, want %s/%s/%s", c.ua, b, o, d, c.browser, c.os, c.device)
}
}
}
func TestWebsiteAnalytics(t *testing.T) {
s, _, _ := newTestServer(t)
s.st.put("website", "w1", &Website{ID: "w1", Name: "Blog", Domain: "example.com"})
ts := httptest.NewServer(s.Handler())
defer ts.Close()
send := func(ua string, body string) int {
req, _ := http.NewRequest("POST", ts.URL+"/api/send", strings.NewReader(body))
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("User-Agent", ua)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
return resp.StatusCode
}
view := func(ua, path, ref string) {
b, _ := json.Marshal(map[string]string{"website": "w1", "hostname": "www.example.com", "path": path, "referrer": ref, "language": "en-GB"})
if code := send(ua, string(b)); code != 204 {
t.Fatalf("send: %d", code)
}
}
view(uaChrome, "/", "https://news.example.org/story?id=1")
view(uaChrome, "/about?utm=x", "")
view(uaPhone, "/", "")
send(uaChrome, `{"website":"w1","hostname":"example.com","path":"/pricing","event":"signup","data":{"plan":"pro"}}`)
// Things that must not be counted.
send("Googlebot/2.1", `{"website":"w1","hostname":"example.com","path":"/bot"}`)
send(uaChrome, `{"website":"w1","hostname":"evil.test","path":"/wrong-host"}`)
send(uaChrome, `{"website":"nope","hostname":"example.com","path":"/unknown-site"}`)
send(uaChrome, `not json`)
c := newAPIClient(t, ts)
if code := c.do("GET", "/api/v1/websites/w1/stats", nil, nil); code != 401 {
t.Fatalf("stats without sign-in: %d", code)
}
c.setup()
var st siteStats
if code := c.do("GET", "/api/v1/websites/w1/stats?range=7d", nil, &st); code != 200 {
t.Fatalf("stats: %d", code)
}
if st.Views != 3 || st.Visitors != 2 || st.Visits != 2 || st.BounceRate != 50 {
t.Fatalf("totals: views %d visitors %d visits %d bounce %.0f", st.Views, st.Visitors, st.Visits, st.BounceRate)
}
has := func(rows []topRow, name string, views int) bool {
for _, r := range rows {
if r.Name == name && r.Views == views {
return true
}
}
return false
}
for _, c := range []struct {
what string
rows []topRow
name string
n int
}{
{"page /", st.Pages, "/", 2}, {"page /about without the query", st.Pages, "/about", 1},
{"referrer host only", st.Referrers, "news.example.org", 1}, {"direct", st.Referrers, "Direct", 2},
{"browser", st.Browsers, "Chrome", 2}, {"os", st.Systems, "iOS", 1},
{"device", st.Devices, "Mobile", 1}, {"language", st.Languages, "en", 3}, {"event", st.Events, "signup", 1},
} {
if !has(c.rows, c.name, c.n) {
t.Errorf("%s: %q x%d not in %+v", c.what, c.name, c.n, c.rows)
}
}
for _, p := range st.Pages {
if p.Name == "/bot" || p.Name == "/wrong-host" || p.Name == "/unknown-site" || p.Name == "/pricing" {
t.Errorf("counted something it should not: %+v", p)
}
}
if st.Online != 2 {
t.Fatalf("online %d", st.Online)
}
nonEmpty := 0
for _, p := range st.Series {
if p.Views > 0 {
nonEmpty++
}
}
if nonEmpty != 1 || len(st.Series) < 7 {
t.Fatalf("series: %d points, %d with views", len(st.Series), nonEmpty)
}
// No IP address or user agent is stored.
var n int
s.st.db.QueryRow(`SELECT count(*) FROM hits WHERE visitor LIKE '%127.0.0.1%' OR browser LIKE '%Mozilla%'`).Scan(&n)
if n != 0 {
t.Fatal("raw identifiers stored")
}
// The script and the browser preflight.
resp, _ := http.Get(ts.URL + "/script.js")
body := make([]byte, 64)
resp.Body.Read(body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(resp.Header.Get("Content-Type"), "javascript") {
t.Fatalf("script.js: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
req, _ := http.NewRequest("OPTIONS", ts.URL+"/api/send", nil)
resp, _ = http.DefaultClient.Do(req)
resp.Body.Close()
if resp.StatusCode != 204 || resp.Header.Get("Access-Control-Allow-Origin") != "*" {
t.Fatalf("preflight: %d", resp.StatusCode)
}
// Deleting the website deletes its hits.
if code := c.do("DELETE", "/api/v1/websites/w1", nil, nil); code != 200 {
t.Fatalf("delete: %d", code)
}
s.st.db.QueryRow(`SELECT count(*) FROM hits`).Scan(&n)
if n != 0 {
t.Fatalf("%d hits left after deleting the website", n)
}
}
func TestHitRateLimitAndProxy(t *testing.T) {
s, _, _ := newTestServer(t)
for i := 0; i < 120; i++ {
if !s.hitAllowed("1.2.3.4") {
t.Fatalf("hit %d refused too early", i)
}
}
if s.hitAllowed("1.2.3.4") {
t.Fatal("the 121st hit in a minute should be refused")
}
if !s.hitAllowed("5.6.7.8") {
t.Fatal("another address has its own allowance")
}
r := httptest.NewRequest("GET", "/", nil)
r.RemoteAddr = "10.0.0.1:5555"
r.Header.Set("X-Forwarded-For", "6.6.6.6, 9.9.9.9")
if ip := s.realIP(r); ip != "10.0.0.1" {
t.Fatalf("without TrustProxy the connection address is used, got %s", ip)
}
s.opts.TrustProxy = true
if ip := s.realIP(r); ip != "9.9.9.9" {
t.Fatalf("the proxy's entry is the last one, got %s", ip)
}
}
+69 -11
View File
@@ -25,6 +25,9 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /status.json", s.statusJSON) mux.HandleFunc("GET /status.json", s.statusJSON)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
mux.HandleFunc("GET /metrics", s.metrics) mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /script.js", s.serveTracker)
mux.HandleFunc("POST /api/send", s.collect)
mux.HandleFunc("OPTIONS /api/send", s.collect)
mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat) mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat)
mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat) mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat)
@@ -105,6 +108,11 @@ func (s *Server) Handler() http.Handler {
} }
return nil return nil
}, nil) }, nil)
s.crud(mux, "websites", "website", true, func() any { return &Website{} }, prepareWebsite, nil)
mux.HandleFunc("GET /api/v1/websites/{id}/stats", s.auth(false, s.websiteStatsHandler))
mux.HandleFunc("GET /api/v1/website-summary", s.auth(false, s.websiteSummary))
mux.HandleFunc("GET /api/v1/system/update", s.auth(true, s.updateStatus))
mux.HandleFunc("POST /api/v1/system/update", s.auth(true, s.updateApply))
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel)) mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow)) mux.HandleFunc("GET /api/v1/oncall", s.auth(false, s.onCallNow))
@@ -347,6 +355,9 @@ func (s *Server) crud(mux *http.ServeMux, path, kind string, adminWrite bool, ne
if kind == "monitor" { if kind == "monitor" {
s.deleteMonitorData(id) s.deleteMonitorData(id)
} }
if kind == "website" {
s.deleteWebsiteData(id)
}
fail2(w, s.st.del(kind, id)) fail2(w, s.st.del(kind, id))
})) }))
} }
@@ -518,11 +529,14 @@ func (s *Server) publishHTTP(w http.ResponseWriter, r *http.Request) {
bad(w, err.Error()) bad(w, err.Error())
return return
} }
m.Event = "message"
writeJSON(w, 200, m) writeJSON(w, 200, m)
} }
// subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json). // subscribeHTTP streams a topic as server-sent events (/sse) or JSON lines (/json).
// ?since=<unix seconds>|all replays stored messages first. // ?since= replays stored messages first: "all", unix seconds, a duration such as 10m, or the id
// of the last message the client saw. ?poll=1 sends only that replay and then ends, which is
// how the ntfy apps catch up.
func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
topic := r.PathValue("topic") topic := r.PathValue("topic")
if !validTopic(topic) { if !validTopic(topic) {
@@ -543,7 +557,14 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
bw := bufio.NewWriter(w) bw := bufio.NewWriter(w)
event := func(kind string) {
b, _ := json.Marshal(map[string]any{"id": newID(), "time": s.now().Unix(), "event": kind, "topic": topic})
fmt.Fprintf(bw, "%s\n", b)
bw.Flush()
fl.Flush()
}
write := func(m Message) bool { write := func(m Message) bool {
m.Event = "message"
b, _ := json.Marshal(m) b, _ := json.Marshal(m)
var err error var err error
if sse { if sse {
@@ -560,21 +581,22 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
defer unsub() defer unsub()
seen := map[string]bool{} seen := map[string]bool{}
if q := r.URL.Query().Get("since"); q != "" { if q := r.URL.Query().Get("since"); q != "" {
since := int64(0) for _, m := range s.replaySince(topic, q) {
if q != "all" {
since, _ = strconv.ParseInt(q, 10, 64)
}
for _, m := range s.MessagesSince(topic, since, 500) {
seen[m.ID] = true seen[m.ID] = true
if !write(m) { if !write(m) {
return return
} }
} }
} }
if r.URL.Query().Get("poll") == "1" {
return
}
if sse { if sse {
fmt.Fprint(bw, ": connected\n\n") fmt.Fprint(bw, ": connected\n\n")
bw.Flush() bw.Flush()
fl.Flush() fl.Flush()
} else {
event("open")
} }
keepalive := time.NewTicker(30 * time.Second) keepalive := time.NewTicker(30 * time.Second)
defer keepalive.Stop() defer keepalive.Stop()
@@ -594,14 +616,39 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
case <-keepalive.C: case <-keepalive.C:
if sse { if sse {
fmt.Fprint(bw, ": keepalive\n\n") fmt.Fprint(bw, ": keepalive\n\n")
} else {
fmt.Fprint(bw, "\n")
}
bw.Flush() bw.Flush()
fl.Flush() fl.Flush()
} else {
event("keepalive")
} }
} }
} }
}
// replaySince returns the stored messages a subscriber should see first for a ?since= value.
func (s *Server) replaySince(topic, q string) []Message {
if q == "all" {
return s.MessagesSince(topic, 0, 500)
}
if n, err := strconv.ParseInt(q, 10, 64); err == nil {
return s.MessagesSince(topic, n, 500)
}
if d, err := time.ParseDuration(q); err == nil {
return s.MessagesSince(topic, s.now().Add(-d).Unix(), 500)
}
// Otherwise it is the id of the last message the client has: send what came after it.
var at int64
if s.st.db.QueryRow(`SELECT time FROM messages WHERE topic=? AND id=?`, topic, q).Scan(&at) != nil {
return nil
}
all := s.MessagesSince(topic, at, 500)
for i, m := range all {
if m.ID == q {
return all[i+1:]
}
}
return all
}
// ---------- alerts in ---------- // ---------- alerts in ----------
@@ -768,6 +815,7 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
Status string `json:"status"` Status string `json:"status"`
Uptime float64 `json:"uptime"` Uptime float64 `json:"uptime"`
Since int64 `json:"since"` Since int64 `json:"since"`
Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today
} }
type inc struct { type inc struct {
Title string `json:"title"` Title string `json:"title"`
@@ -781,14 +829,24 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
Monitors []mon `json:"monitors"` Monitors []mon `json:"monitors"`
Incidents []inc `json:"incidents"` Incidents []inc `json:"incidents"`
Overall string `json:"overall"` Overall string `json:"overall"`
}{[]mon{}, []inc{}, "operational"} Today string `json:"today"` // the UTC date of the last bar
}{[]mon{}, []inc{}, "operational", s.now().UTC().Format("2006-01-02")}
mons, _ := listOf[Monitor](s.st, "monitor") mons, _ := listOf[Monitor](s.st, "monitor")
for _, m := range mons { for _, m := range mons {
if !m.Public || !m.Enabled { if !m.Public || !m.Enabled {
continue continue
} }
st := s.monitorState(m.ID) st := s.monitorState(m.ID)
out.Monitors = append(out.Monitors, mon{m.Name, st.Status, st.Uptime30, st.Since}) days := s.dailyUptime(m.ID, uptimeDays, s.now())
var checks, fails int
for _, d := range days {
checks, fails = checks+d[0], fails+d[1]
}
uptime := -1.0 // no checks yet
if checks > 0 {
uptime = float64(checks-fails) * 100 / float64(checks)
}
out.Monitors = append(out.Monitors, mon{m.Name, st.Status, uptime, st.Since, days})
if st.Status == "down" { if st.Status == "down" {
out.Overall = "outage" out.Overall = "outage"
} }
+1 -10
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -144,19 +143,11 @@ func (s *Server) noteFailure(ip string) {
s.loginMu.Unlock() s.loginMu.Unlock()
} }
func clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
var errThrottled = errors.New("too many attempts, try again in a few minutes") var errThrottled = errors.New("too many attempts, try again in a few minutes")
// login checks credentials and starts a session. // login checks credentials and starts a session.
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) { func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
ip := clientIP(r) ip := s.realIP(r)
if s.throttled(ip) { if s.throttled(ip) {
return nil, errThrottled return nil, errThrottled
} }
+88
View File
@@ -0,0 +1,88 @@
package sandbar
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Every check lands in a per-day count that outlives the raw checks, and the status page
// gets one [checks, failed] pair per day, oldest first and ending today.
func TestUptimeBars(t *testing.T) {
s, clk, _ := newTestServer(t)
m := &Monitor{ID: "m", Name: "Site", Kind: "http", Target: "http://x", Enabled: true, Public: true, Retries: 1}
s.st.put("monitor", m.ID, m)
// Two days ago: 4 checks, 1 failed. Yesterday: nothing. Today: 2 checks, both fine.
clk.Advance(-48 * time.Hour)
for i := 0; i < 4; i++ {
var err error
if i == 3 {
err = errors.New("timeout")
}
s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, err)
}
clk.Advance(48 * time.Hour)
s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, nil)
s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, nil)
days := s.dailyUptime(m.ID, 5, clk.Now())
want := [][2]int{{0, 0}, {0, 0}, {4, 1}, {0, 0}, {2, 0}}
for i := range want {
if days[i] != want[i] {
t.Fatalf("days = %v, want %v", days, want)
}
}
// The rollup survives pruning the raw checks.
s.st.prune(clk.Now().AddDate(0, 0, 60), 30, 1, 365)
if got := s.dailyUptime(m.ID, 5, clk.Now()); got[2] != [2]int{4, 1} {
t.Fatalf("rollup lost after pruning: %v", got)
}
ts := httptest.NewServer(s.Handler())
defer ts.Close()
resp, err := http.Get(ts.URL + "/status.json")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Today string
Monitors []struct {
Uptime float64
Days [][2]int
}
}
json.NewDecoder(resp.Body).Decode(&out)
if out.Today != "2026-01-05" || len(out.Monitors) != 1 || len(out.Monitors[0].Days) != uptimeDays {
t.Fatalf("status.json: %+v", out)
}
m0 := out.Monitors[0]
if last := m0.Days[uptimeDays-1]; last != [2]int{2, 0} {
t.Fatalf("today's bar: %v", last)
}
if want := 100 * 5.0 / 6.0; m0.Uptime < want-0.01 || m0.Uptime > want+0.01 {
t.Fatalf("uptime %.2f, want %.2f", m0.Uptime, want)
}
}
// A database from before the daily table gets it built from the checks it has.
func TestDailyBackfill(t *testing.T) {
s, clk, _ := newTestServer(t)
now := clk.Now().Unix()
s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES('m',?,1,5,''),('m',?,0,5,'x')`, now, now+1)
s.st.db.Exec(`DELETE FROM daily`)
var have int
s.st.db.QueryRow(`SELECT count(*) FROM daily`).Scan(&have)
if have != 0 {
t.Fatal("setup")
}
s.st.backfillDaily()
if got := s.dailyUptime("m", 1, clk.Now()); got[0] != [2]int{2, 1} {
t.Fatalf("backfill: %v", got)
}
}
+34
View File
@@ -233,6 +233,37 @@ func (s *Server) monitorState(id string) MonitorState {
return st return st
} }
// uptimeDays is how many days of bars the status page shows.
const uptimeDays = 90
// dailyUptime returns [checks, failed] for each of the last n UTC days, oldest first, so the
// last entry is today. A day with no checks is [0, 0].
func (s *Server) dailyUptime(monitorID string, n int, now time.Time) [][2]int {
out := make([][2]int, n)
today := now.UTC().Truncate(24 * time.Hour)
rows, err := s.st.db.Query(`SELECT day,checks,fails FROM daily WHERE monitor_id=? AND day>=?`,
monitorID, today.AddDate(0, 0, -(n-1)).Format("2006-01-02"))
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var day string
var c, f int
if rows.Scan(&day, &c, &f) != nil {
continue
}
d, err := time.Parse("2006-01-02", day)
if err != nil {
continue
}
if i := n - 1 - int(today.Sub(d).Hours()/24); i >= 0 && i < n {
out[i] = [2]int{c, f}
}
}
return out
}
func (s *Server) saveState(st MonitorState) { func (s *Server) saveState(st MonitorState) {
s.st.db.Exec(`INSERT INTO mstate(monitor_id,status,since,last_check,last_ms,fails,last_ping,detail) VALUES(?,?,?,?,?,?,?,?) s.st.db.Exec(`INSERT INTO mstate(monitor_id,status,since,last_check,last_ms,fails,last_ping,detail) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(monitor_id) DO UPDATE SET status=excluded.status, since=excluded.since, last_check=excluded.last_check, ON CONFLICT(monitor_id) DO UPDATE SET status=excluded.status, since=excluded.since, last_check=excluded.last_check,
@@ -309,6 +340,8 @@ func (s *Server) recordResult(m *Monitor, st MonitorState, now time.Time, dur ti
okInt = 1 okInt = 1
} }
s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES(?,?,?,?,?)`, m.ID, now.Unix(), okInt, dur.Milliseconds(), errText(err)) s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES(?,?,?,?,?)`, m.ID, now.Unix(), okInt, dur.Milliseconds(), errText(err))
s.st.db.Exec(`INSERT INTO daily(monitor_id,day,checks,fails) VALUES(?,?,1,?)
ON CONFLICT(monitor_id,day) DO UPDATE SET checks=checks+1, fails=fails+excluded.fails`, m.ID, now.UTC().Format("2006-01-02"), 1-okInt)
st.LastCheck, st.LastMS, st.Detail = now.Unix(), int(dur.Milliseconds()), errText(err) st.LastCheck, st.LastMS, st.Detail = now.Unix(), int(dur.Milliseconds()), errText(err)
key := "monitor:" + m.ID key := "monitor:" + m.ID
if ok { if ok {
@@ -369,6 +402,7 @@ func (s *Server) Heartbeat(token string) bool {
func (s *Server) deleteMonitorData(id string) { func (s *Server) deleteMonitorData(id string) {
s.st.db.Exec(`DELETE FROM mstate WHERE monitor_id=?`, id) s.st.db.Exec(`DELETE FROM mstate WHERE monitor_id=?`, id)
s.st.db.Exec(`DELETE FROM checks WHERE monitor_id=?`, id) s.st.db.Exec(`DELETE FROM checks WHERE monitor_id=?`, id)
s.st.db.Exec(`DELETE FROM daily WHERE monitor_id=?`, id)
s.ResolveAlertKey("monitor:"+id, "monitor deleted") s.ResolveAlertKey("monitor:"+id, "monitor deleted")
} }
+2 -1
View File
@@ -25,8 +25,9 @@ import (
type Message struct { type Message struct {
ID string `json:"id"` ID string `json:"id"`
Topic string `json:"topic"`
Time int64 `json:"time"` Time int64 `json:"time"`
Event string `json:"event"` // always "message"; the ntfy apps ignore lines without it
Topic string `json:"topic"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message"` Message string `json:"message"`
Priority int `json:"priority"` Priority int `json:"priority"`
+50
View File
@@ -453,6 +453,56 @@ func TestAPIAuthSetupAndTopics(t *testing.T) {
t.Fatal("subscriber got nothing") t.Fatal("subscriber got nothing")
} }
// The ntfy apps catch up with ?poll=1&since=<last id> and ignore lines without "event".
var ids []string
for _, body := range []string{"one", "two", "three"} {
r, _ := http.NewRequest("POST", ts.URL+"/phone", strings.NewReader(body))
r.Header.Set("Authorization", "Bearer "+key["token"])
resp, err := http.DefaultClient.Do(r)
if err != nil || resp.StatusCode != 200 {
t.Fatalf("publish %s: %v %v", body, err, resp)
}
var pm Message
json.NewDecoder(resp.Body).Decode(&pm)
resp.Body.Close()
if pm.Event != "message" {
t.Fatalf("publish response event: %q", pm.Event)
}
ids = append(ids, pm.ID)
}
pollLines := func(query string) []Message {
req, _ := http.NewRequest("GET", ts.URL+"/phone/json?poll=1&"+query, nil)
req.Header.Set("Authorization", "Bearer "+key["token"])
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var got []Message
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
var m Message
json.Unmarshal(sc.Bytes(), &m)
if m.Event != "message" {
t.Fatalf("poll line without event: %s", sc.Text())
}
got = append(got, m)
}
return got
}
if got := pollLines("since=all"); len(got) != 3 {
t.Fatalf("poll since=all: %d messages", len(got))
}
if got := pollLines("since=" + ids[0]); len(got) != 2 || got[0].Message != "two" {
t.Fatalf("poll since=<id>: %+v", got)
}
if got := pollLines("since=" + ids[2]); len(got) != 0 {
t.Fatalf("poll since=<last id>: %+v", got)
}
if got := pollLines("since=10m"); len(got) != 3 {
t.Fatalf("poll since=10m: %d messages", len(got))
}
// Reserved topics are refused. // Reserved topics are refused.
for _, reserved := range []string{"/api", "/status"} { for _, reserved := range []string{"/api", "/status"} {
r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x")) r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x"))
+22 -2
View File
@@ -25,6 +25,17 @@ type Options struct {
SMTP SMTPConfig SMTP SMTPConfig
MessageDays int // how long topic messages are kept (default 30) MessageDays int // how long topic messages are kept (default 30)
CheckDays int // how long monitor results are kept (default 30) CheckDays int // how long monitor results are kept (default 30)
HitDays int // how long website analytics are kept (default 365)
TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
// ("dev") never updates. Restart is called after a new binary is installed and should close
// what the process holds open before re-executing it.
Version string
AutoUpdate bool
UpdateServer string // default https://git.veillax.com
UpdateRepo string // default Veillax/sandbar
Restart func(exe string) error
// Tests replace these. // Tests replace these.
Now func() time.Time Now func() time.Time
@@ -44,6 +55,12 @@ type Server struct {
loginMu sync.Mutex loginMu sync.Mutex
loginTry map[string][]time.Time loginTry map[string][]time.Time
upd updateState
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
anaSecret string
anaTry map[string][]time.Time
stop chan struct{} stop chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
} }
@@ -55,7 +72,10 @@ func New(opts Options, st *Store) *Server {
if opts.CheckDays <= 0 { if opts.CheckDays <= 0 {
opts.CheckDays = 30 opts.CheckDays = 30
} }
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, stop: make(chan struct{})} if opts.HitDays <= 0 {
opts.HitDays = 365
}
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, anaTry: map[string][]time.Time{}, stop: make(chan struct{})}
} }
func (s *Server) now() time.Time { func (s *Server) now() time.Time {
@@ -69,7 +89,7 @@ func (s *Server) now() time.Time {
func (s *Server) Start() { func (s *Server) Start() {
s.every(time.Second, func() { s.runDueMonitors(s.now()) }) s.every(time.Second, func() { s.runDueMonitors(s.now()) })
s.every(5*time.Second, func() { s.escalate(s.now()) }) s.every(5*time.Second, func() { s.escalate(s.now()) })
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays) }) s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays, s.opts.HitDays) })
} }
func (s *Server) Close() { func (s *Server) Close() {
+25 -2
View File
@@ -45,6 +45,15 @@ CREATE INDEX IF NOT EXISTS messages_topic ON messages(topic, time);
CREATE TABLE IF NOT EXISTS checks ( CREATE TABLE IF NOT EXISTS checks (
monitor_id TEXT NOT NULL, time INTEGER NOT NULL, ok INTEGER NOT NULL, ms INTEGER NOT NULL, detail TEXT NOT NULL); monitor_id TEXT NOT NULL, time INTEGER NOT NULL, ok INTEGER NOT NULL, ms INTEGER NOT NULL, detail TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS checks_monitor ON checks(monitor_id, time); CREATE INDEX IF NOT EXISTS checks_monitor ON checks(monitor_id, time);
-- One row per monitor per UTC day, kept far longer than raw checks: it feeds the status page's uptime bars.
CREATE TABLE IF NOT EXISTS daily (
monitor_id TEXT NOT NULL, day TEXT NOT NULL, checks INTEGER NOT NULL, fails INTEGER NOT NULL, PRIMARY KEY (monitor_id, day));
-- Website analytics: one row per page view or event. No IP address or user agent is kept, only labels.
CREATE TABLE IF NOT EXISTS hits (
website TEXT NOT NULL, time INTEGER NOT NULL, visitor TEXT NOT NULL, event TEXT NOT NULL, path TEXT NOT NULL,
referrer TEXT NOT NULL, browser TEXT NOT NULL, os TEXT NOT NULL, device TEXT NOT NULL, country TEXT NOT NULL,
lang TEXT NOT NULL, data TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS hits_site ON hits(website, time);
CREATE TABLE IF NOT EXISTS mstate ( CREATE TABLE IF NOT EXISTS mstate (
monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL, monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL,
last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL); last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL);
@@ -78,7 +87,19 @@ func OpenStore(dir string, box *secretbox.Box) (*Store, error) {
db.Close() db.Close()
return nil, err return nil, err
} }
return &Store{db: db, box: box}, nil st := &Store{db: db, box: box}
st.backfillDaily()
return st, nil
}
// backfillDaily fills the daily table from the raw checks, but only when it is empty: a database
// from before the table existed. After that every check is counted as it is recorded.
func (s *Store) backfillDaily() {
var have int
if s.db.QueryRow(`SELECT count(*) FROM daily`).Scan(&have) == nil && have == 0 {
s.db.Exec(`INSERT OR IGNORE INTO daily(monitor_id,day,checks,fails)
SELECT monitor_id, date(time,'unixepoch'), count(*), count(*)-sum(ok) FROM checks GROUP BY monitor_id, date(time,'unixepoch')`)
}
} }
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
@@ -231,9 +252,11 @@ func (s *Store) events(scope, ref string) []Event {
} }
// prune deletes data past its retention. // prune deletes data past its retention.
func (s *Store) prune(now time.Time, messageDays, checkDays int) { func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
s.db.Exec(`DELETE FROM hits WHERE time < ?`, now.AddDate(0, 0, -hitDays).Unix())
s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix()) s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix())
s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix()) s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix())
s.db.Exec(`DELETE FROM daily WHERE day < ?`, now.UTC().AddDate(0, 0, -400).Format("2006-01-02"))
s.db.Exec(`DELETE FROM sessions WHERE expires < ?`, now.Unix()) s.db.Exec(`DELETE FROM sessions WHERE expires < ?`, now.Unix())
s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix()) s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix())
} }
+255
View File
@@ -0,0 +1,255 @@
package sandbar
import (
"context"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"git.veillax.com/Veillax/reef/selfupdate"
)
// Self-update replaces the running binary with the newest release published on the Gitea
// server (Reef's selfupdate does the download, checksum and swap).
//
// In a container the binary lives in the image, where this user can't write and where a
// change would be lost when the container is recreated. So an update is written to
// <data>/bin/sandbar on the data volume instead, and every start checks that file first
// (UseNewerOverlay): it runs when it is a newer release than the image, and is deleted once
// the image has caught up.
const defaultUpdateServer = "https://git.veillax.com"
type updateState struct {
mu sync.Mutex
running bool
stage string
pct int
line string
err string
checked time.Time // the cached answer from the release server
latest string
newer bool
checkErr string
}
func (s *Server) updateConfig() selfupdate.Config {
o := s.opts
server, repo := o.UpdateServer, o.UpdateRepo
if server == "" {
server = defaultUpdateServer
}
if repo == "" {
repo = "Veillax/sandbar"
}
return selfupdate.Config{
Server: server, Repo: repo, App: "sandbar", Current: o.Version,
ExePath: UpdateTarget(o.DataDir), Restart: o.Restart, Logf: log.Printf,
}
}
// RunAutoUpdate checks for a new release shortly after start and then every few hours until
// ctx ends, installing what it finds and restarting into it.
func (s *Server) RunAutoUpdate(ctx context.Context) {
selfupdate.Run(ctx, s.updateConfig())
}
// canInstall says whether this build can update itself, and if not, why.
func (s *Server) canInstall() (bool, string) {
if _, ok := parseVersion(s.opts.Version); !ok {
return false, "This is a development build, so it does not update itself."
}
if runtime.GOOS == "windows" {
return false, "A running program can't be replaced on Windows. Download the new release and restart."
}
return true, ""
}
// updateStatus reports the running and newest versions. The release server is asked at most
// every ten minutes unless ?fresh=1.
func (s *Server) updateStatus(w http.ResponseWriter, r *http.Request) {
ok, why := s.canInstall()
u := &s.upd
u.mu.Lock()
stale := time.Since(u.checked) > 10*time.Minute || r.URL.Query().Get("fresh") == "1"
running := u.running
u.mu.Unlock()
if stale && !running {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
latest, newer, err := selfupdate.Check(ctx, s.updateConfig())
cancel()
u.mu.Lock()
u.checked, u.latest, u.newer, u.checkErr = time.Now(), latest, newer, ""
if err != nil {
u.checkErr = err.Error()
}
u.mu.Unlock()
}
u.mu.Lock()
out := map[string]any{
"current": s.opts.Version, "autoUpdate": s.opts.AutoUpdate, "canInstall": ok, "reason": why,
"latest": u.latest, "newer": u.newer, "error": u.checkErr,
"running": u.running, "stage": u.stage, "pct": u.pct, "line": u.line, "failed": u.err,
}
u.mu.Unlock()
writeJSON(w, 200, out)
}
// updateApply installs the newest release now. It answers at once; the page polls updateStatus
// for progress, and the server disappears for a moment when it restarts into the new binary.
func (s *Server) updateApply(w http.ResponseWriter, r *http.Request) {
if ok, why := s.canInstall(); !ok {
bad(w, why)
return
}
u := &s.upd
u.mu.Lock()
if u.running {
u.mu.Unlock()
writeJSON(w, 409, map[string]string{"error": "An update is already running."})
return
}
u.running, u.stage, u.pct, u.line, u.err = true, "starting", 0, "", ""
u.mu.Unlock()
cfg := s.updateConfig()
cfg.Progress = func(e selfupdate.Event) {
u.mu.Lock()
defer u.mu.Unlock()
u.stage = e.Stage
if e.Pct >= 0 {
u.pct = e.Pct
}
if e.Line != "" {
u.line = e.Line
}
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
_, err := selfupdate.CheckOnce(ctx, cfg) // when it installs, the process is replaced and this never returns
u.mu.Lock()
u.running = false
if err != nil {
u.err = err.Error()
}
u.checked = time.Time{} // look again next time
u.mu.Unlock()
}()
writeJSON(w, 200, map[string]bool{"started": true})
}
// UpdateTarget is the file an update replaces: the running binary when its folder is writable
// (a plain install), and otherwise <data>/bin/sandbar (a container).
func UpdateTarget(dataDir string) string {
if exe, err := os.Executable(); err == nil {
if exe, err = filepath.EvalSymlinks(exe); err == nil && writableDir(filepath.Dir(exe)) {
return exe
}
}
dir, err := filepath.Abs(filepath.Join(dataDir, "bin"))
if err != nil {
dir = filepath.Join(dataDir, "bin")
}
os.MkdirAll(dir, 0o755)
return filepath.Join(dir, "sandbar")
}
func writableDir(dir string) bool {
f, err := os.CreateTemp(dir, ".sandbar-write-test-*")
if err != nil {
return false
}
f.Close()
os.Remove(f.Name())
return true
}
// UseNewerOverlay runs <data>/bin/sandbar instead of this binary when that file is a newer
// release. It returns when there is nothing to switch to, and otherwise replaces the process.
func UseNewerOverlay(dataDir, current string, logf func(string, ...any)) {
if runtime.GOOS == "windows" || os.Getenv("SANDBAR_NO_OVERLAY") == "1" {
return
}
overlay, err := filepath.Abs(filepath.Join(dataDir, "bin", "sandbar"))
if err != nil {
return
}
ofi, err := os.Stat(overlay)
if err != nil {
return
}
if exe, err := os.Executable(); err == nil {
if efi, err := os.Stat(exe); err == nil && os.SameFile(efi, ofi) {
return // this already is the overlay
}
}
ver := overlayVersion(overlay)
switch {
case ver == "":
logf("ignoring %s: it did not report a version", overlay)
case versionNewer(ver, current):
logf("running %s from %s, which is newer than this image's %s", ver, overlay, current)
if err := selfupdate.Restart(overlay); err != nil {
logf("could not switch to %s: %v", overlay, err)
}
default:
if _, ok := parseVersion(current); ok {
os.Remove(overlay) // the image has caught up with it
}
}
}
// overlayVersion asks a binary for its version, or returns "" if it will not run.
func overlayVersion(path string) string {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, path, "-version").Output()
f := strings.Fields(string(out))
if err != nil || len(f) != 2 || f[0] != "sandbar" {
return ""
}
return f[1]
}
// parseVersion reads vX.Y.Z. Pre-releases and local builds are not versions for this purpose.
func parseVersion(s string) ([3]int, bool) {
var v [3]int
if !strings.HasPrefix(s, "v") || strings.ContainsAny(s, "-+") {
return v, false
}
parts := strings.Split(s[1:], ".")
if len(parts) != 3 {
return v, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return v, false
}
v[i] = n
}
return v, true
}
// versionNewer reports whether a is a later release than b. Either not being a release is false.
func versionNewer(a, b string) bool {
x, ok1 := parseVersion(a)
y, ok2 := parseVersion(b)
if !ok1 || !ok2 {
return false
}
for i := range x {
if x[i] != y[i] {
return x[i] > y[i]
}
}
return false
}
+109
View File
@@ -0,0 +1,109 @@
package sandbar
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestVersionNewer(t *testing.T) {
for _, c := range []struct {
a, b string
want bool
}{
{"v0.1.4", "v0.1.3", true},
{"v0.2.0", "v0.1.9", true},
{"v1.0.0", "v0.9.9", true},
{"v0.1.3", "v0.1.3", false},
{"v0.1.2", "v0.1.3", false},
{"v0.1.10", "v0.1.9", true},
{"v0.2.0-rc1", "v0.1.0", false}, // pre-releases are never offered
{"v0.2.0", "dev", false}, // a development build never updates
{"dev", "v0.1.0", false},
} {
if got := versionNewer(c.a, c.b); got != c.want {
t.Errorf("versionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
// The update page asks the release server and reports whether it has something newer.
func TestUpdateStatus(t *testing.T) {
release := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/Veillax/sandbar/releases/latest" {
http.NotFound(w, r)
return
}
w.Write([]byte(`{"tag_name":"v0.2.0","assets":[]}`))
}))
defer release.Close()
s, _, _ := newTestServer(t)
s.opts.Version, s.opts.UpdateServer = "v0.1.0", release.URL
ts := httptest.NewServer(s.Handler())
defer ts.Close()
c := newAPIClient(t, ts)
if code := c.do("GET", "/api/v1/system/update", nil, nil); code != 401 {
t.Fatalf("signed out: %d", code)
}
c.setup()
var st map[string]any
if code := c.do("GET", "/api/v1/system/update", nil, &st); code != 200 {
t.Fatalf("status: %d", code)
}
if st["current"] != "v0.1.0" || st["latest"] != "v0.2.0" || st["newer"] != true {
t.Fatalf("status: %v", st)
}
if st["error"] != "" {
t.Fatalf("unexpected error: %v", st["error"])
}
// A development build can look but not install.
s.opts.Version = "dev"
s.upd.checked = s.upd.checked.AddDate(-1, 0, 0)
if code := c.do("POST", "/api/v1/system/update", map[string]string{}, &st); code != 400 {
t.Fatalf("a development build must not install: %d %v", code, st)
}
}
// A newer binary on the data volume takes over from the image, and a stale one is removed.
func TestOverlay(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("self-update is not supported on Windows")
}
dir := t.TempDir()
bin := filepath.Join(dir, "bin")
os.MkdirAll(bin, 0o755)
overlay := filepath.Join(bin, "sandbar")
script := func(version string) {
os.WriteFile(overlay, []byte("#!/bin/sh\necho 'sandbar "+version+"'\n"), 0o755)
}
var logged []string
logf := func(f string, a ...any) { logged = append(logged, f) }
// An older overlay is deleted once the image is a release that has caught up.
script("v0.1.0")
UseNewerOverlay(dir, "v0.1.5", logf)
if _, err := os.Stat(overlay); err == nil {
t.Fatal("a stale overlay should be removed")
}
// A development image never deletes or switches to anything.
script("v0.9.0")
UseNewerOverlay(dir, "dev", logf)
if _, err := os.Stat(overlay); err != nil {
t.Fatal("a development build must leave the overlay alone")
}
// Something that does not report a version is ignored, not run.
os.WriteFile(overlay, []byte("#!/bin/sh\necho hello\n"), 0o755)
UseNewerOverlay(dir, "v0.1.5", logf)
if len(logged) == 0 || !strings.Contains(logged[len(logged)-1], "did not report a version") {
t.Fatalf("log: %v", logged)
}
}
+123 -2
View File
@@ -227,6 +227,28 @@
.feed .empty { margin:0; } .feed .empty { margin:0; }
.msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); } .msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); }
.msg .tag { margin-right:var(--s1); } .msg .tag { margin-right:var(--s1); }
/* website analytics */
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:var(--s3); margin-bottom:var(--s4); }
.stat { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); }
.stat .n { font-size:var(--text-xl); font-weight:600; font-variant-numeric:tabular-nums; }
.stat .l { font-size:var(--text-sm); color:var(--text-muted); }
.chart { display:flex; align-items:flex-end; gap:2px; height:180px; margin-top:var(--s3); }
.chart .col { position:relative; flex:1; min-width:0; height:100%; display:flex; align-items:flex-end; }
.chart .col:hover { background:var(--surface-2); }
.chart .v, .chart .u { position:absolute; left:0; right:0; bottom:0; border-radius:2px 2px 0 0; }
.chart .v { background:color-mix(in srgb, var(--accent) 35%, var(--surface)); }
.chart .u { background:var(--accent); }
.axis2 { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
.legend { display:flex; gap:var(--s4); font-size:var(--text-sm); color:var(--text-muted); flex-wrap:wrap; }
.legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:var(--s1); vertical-align:baseline; }
.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:var(--s4); margin-top:var(--s4); }
.cols .card { margin:0; }
.top { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
.top td { padding:var(--s1) var(--s2); }
.top td:first-child { background:linear-gradient(to right, var(--accent-soft) var(--w), transparent var(--w)); overflow-wrap:anywhere; }
.top td.num { text-align:right; white-space:nowrap; width:1%; }
.top th { text-align:right; font-weight:500; color:var(--text-muted); padding:var(--s1) var(--s2); }
.top th:first-child { text-align:left; }
</style> </style>
</head> </head>
<body> <body>
@@ -360,6 +382,7 @@ const ICONS = {
incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>', incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>', oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>', monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
websites:'<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>', topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>', channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>',
status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>', status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
@@ -420,6 +443,7 @@ const PAGES = {
incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'], incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'],
oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'], oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'],
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'], monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'], topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'], channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
users: ['People', 'Everyone who can sign in, and how to reach them.'], users: ['People', 'Everyone who can sign in, and how to reach them.'],
@@ -427,7 +451,7 @@ const PAGES = {
}; };
const NAV = [ const NAV = [
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]], ['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
['Observe', [['monitors', 'Monitors', 'monitors'], ['topics', 'Topics', 'topics']]], ['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]], ['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
]; ];
function route() { function route() {
@@ -754,9 +778,106 @@ function passwordDialog(u) {
dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); }); dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); });
} }
// ---------- websites (analytics) ----------
const RANGES = [['24h', 'Last 24 hours'], ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ['90d', 'Last 90 days']];
const num = n => Number(n || 0).toLocaleString();
function regionName(code) { try { return new Intl.DisplayNames(undefined, {type:'region'}).of(code) || code; } catch (e) { return code; } }
function languageName(code) { try { return new Intl.DisplayNames(undefined, {type:'language'}).of(code) || code; } catch (e) { return code; } }
views.websites = async () => {
if (S.arg) return websiteDetail(S.arg);
const admin = S.me.role === 'admin';
S.headerAction = admin ? ['Add website', () => websiteDialog()] : null;
const [sites, sum] = await Promise.all([api('GET', V1 + '/websites'), api('GET', V1 + '/website-summary')]);
if (!sites.length) return emptyState('websites', 'No websites yet', 'Add a website, then paste one line of code into its pages. Visitors and page views show up here. No cookies are set and no personal data is kept.',
admin ? h('button', {class:'btn', onclick:() => websiteDialog()}, 'Add website') : null);
return table(['Website', 'Visitors, last 24 hours', 'Page views, last 24 hours', ''], sites.map(w => { const s = sum[w.id] || {};
return h('tr', {}, h('td', {}, h('a', {href:'#websites/' + w.id}, w.name), w.domain ? h('div', {class:'small dim'}, w.domain) : null),
h('td', {class:'num'}, num(s.visitors)), h('td', {class:'num'}, num(s.views)),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code'),
admin ? h('button', {class:'ghost', onclick:() => websiteDialog(w)}, 'Edit') : null,
admin ? h('button', {class:'ghost danger', onclick:ask({title:`Delete ${w.name}?`, body:'The website and all of its visits and events are removed. This cannot be undone.', confirm:'Delete website'}, async () => { await api('DELETE', `${V1}/websites/${w.id}`); toast('Website deleted.'); refresh(); })}, 'Delete') : null)));
}));
};
function websiteDialog(w) {
const e = !!w, f = { name: h('input', {value:w ? w.name : ''}), domain: h('input', {value:w ? w.domain : '', placeholder:'example.com'}) };
dialogForm(e ? 'Edit ' + w.name : 'Add a website', null, [field('Name', f.name), field('Domain', f.domain, 'Optional. When set, visits from other domains are ignored, which stops others counting against your site.')], e ? 'Save changes' : 'Add website',
async () => { const saved = await api(e ? 'PUT' : 'POST', e ? `${V1}/websites/${w.id}` : V1 + '/websites', {...(w || {}), name:f.name.value, domain:f.domain.value}); toast(e ? 'Website saved.' : 'Website added.'); await refresh(); if (!e) trackingDialog(saved); });
}
function trackingDialog(w) {
const tag = `<script defer src="${origin()}/script.js" data-website="${w.id}"><\/script>`;
const done = modal('Tracking code for ' + w.name, h('div', {class:'stack'},
h('p', {class:'dim', style:'margin:0'}, 'Paste this inside the head of every page you want to count.'), cmdBox(tag),
h('p', {class:'dim', style:'margin:0'}, 'Single-page sites are counted as people move between pages. To count something else, such as a sign-up, call this from your code:'),
cmdBox('sandbar.track("signup", { plan: "pro" })'),
h('p', {class:'small dim', style:'margin:0'}, 'The script sets no cookies, keeps no IP address, and does nothing when the visitor has Do Not Track on. Behind a reverse proxy, set SANDBAR_TRUST_PROXY=1 so visitors are told apart.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done'))));
}
async function websiteDetail(id) {
S.noHeader = true;
const range = S.siteRange || '7d';
const [sites, st] = await Promise.all([api('GET', V1 + '/websites'), api('GET', `${V1}/websites/${id}/stats?range=${range}`)]);
setTimeout(() => { S.noHeader = false; });
const w = sites.find(x => x.id === id);
if (!w) return emptyState('websites', 'That website no longer exists', 'It may have been deleted.', h('a', {class:'btn', href:'#websites'}, 'Back to websites'));
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Time range'}, RANGES.map(([k, l]) => h('button', {class:range === k ? 'on' : '', 'aria-pressed':range === k, onclick:() => { S.siteRange = k; refresh(); }}, l)));
const tile = (n, l) => h('div', {class:'stat'}, h('div', {class:'n'}, n), h('div', {class:'l'}, l));
const stats = h('div', {class:'stats'}, tile(num(st.visitors), 'Visitors'), tile(num(st.views), 'Page views'), tile(num(st.visits), 'Visits'),
tile(st.bounceRate >= 0 ? Math.round(st.bounceRate) + '%' : 'No data', 'Bounce rate'), tile(num(st.online), 'Online now'));
const max = Math.max(1, ...st.series.map(p => p.views));
const fmt = p => new Date(p.t * 1000).toLocaleString(undefined, st.bucket === 'hour' ? {month:'short', day:'numeric', hour:'numeric'} : {month:'short', day:'numeric', year:'numeric'});
const chart = h('div', {class:'chart', role:'img', 'aria-label':`${num(st.views)} page views from ${num(st.visitors)} visitors in the ${RANGES.find(r => r[0] === range)[1].toLowerCase()}`},
st.series.map(p => h('div', {class:'col', title:`${fmt(p)}: ${num(p.visitors)} visitors, ${num(p.views)} page views`},
h('div', {class:'v', style:`height:${p.views ? Math.max(2, p.views * 100 / max) : 0}%`}), h('div', {class:'u', style:`height:${p.visitors ? Math.max(2, p.visitors * 100 / max) : 0}%`}))));
const list = (title, rows, label, nameOf) => {
const top = Math.max(1, ...rows.map(r => r.views));
return h('div', {class:'card'}, h('h2', {}, title), rows.length ? h('table', {class:'top'}, h('thead', {}, h('tr', {}, h('th', {scope:'col'}, label), h('th', {scope:'col'}, 'Visitors'), h('th', {scope:'col'}, 'Views'))),
h('tbody', {}, rows.map(r => h('tr', {}, h('td', {style:`--w:${Math.round(r.views * 100 / top)}%`}, nameOf ? nameOf(r.name) : r.name), h('td', {class:'num'}, num(r.visitors)), h('td', {class:'num'}, num(r.views))))))
: h('p', {class:'dim', style:'margin:0'}, 'Nothing yet.'));
};
const nothing = !st.views && !st.events.length;
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#websites', class:'small'}, 'Back to websites'), h('h1', {}, w.name), h('p', {}, w.domain || 'Any domain')),
h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code')),
tabs, h('div', {style:'height:16px'}), stats,
nothing ? emptyState('websites', 'No visits in this period', 'Paste the tracking code into your pages, then open one. The first visit appears within a few seconds.', h('button', {class:'btn', onclick:() => trackingDialog(w)}, 'Show tracking code')) : h('div', {},
card('Visitors and page views', h('div', {class:'legend'}, h('span', {}, h('i', {style:'background:var(--accent)'}), 'Visitors'), h('span', {}, h('i', {style:'background:color-mix(in srgb, var(--accent) 35%, var(--surface))'}), 'Page views')), chart,
h('div', {class:'axis2', 'aria-hidden':'true'}, h('span', {}, fmt(st.series[0])), h('span', {}, 'Now'))),
h('div', {class:'cols'}, list('Pages', st.pages, 'Path'), list('Referrers', st.referrers, 'Source'), list('Browsers', st.browsers, 'Browser'), list('Operating systems', st.systems, 'System'),
list('Devices', st.devices, 'Device'), list('Countries', st.countries, 'Country', n => n.length === 2 ? regionName(n) : n), list('Languages', st.languages, 'Language', n => n.length <= 3 ? languageName(n) : n),
list('Events', st.events, 'Event'))));
}
// ---------- account ---------- // ---------- account ----------
// Administrators see which release is running and can install a newer one. The server restarts
// into the new binary, so the page waits for it to come back.
function updateCard(u) {
const status = h('p', {style:'margin:0'}), detail = h('p', {class:'dim', style:'margin:0'}), actions = h('div', {class:'actions form-actions'});
const paint = u => {
status.replaceChildren(`Running ${u.current}. `, u.error ? 'The newest release could not be checked.' : u.newer ? `${u.latest} is available.` : u.latest ? 'This is the newest release.' : '');
detail.textContent = u.error ? u.error : !u.canInstall ? u.reason : u.autoUpdate ? 'Automatic updates are on. Sandbar checks every few hours.' : 'Automatic updates are off. Set SANDBAR_AUTO_UPDATE=1 and restart to turn them on.';
actions.replaceChildren(...[h('button', {class:'ghost', onclick:act(async () => { paint(await api('GET', V1 + '/system/update?fresh=1')); })}, 'Check again'),
u.newer && u.canInstall ? h('button', {class:'btn', onclick:ask({title:`Update to ${u.latest}?`, danger:false, confirm:'Update now',
body:'Sandbar downloads the release, checks it, and restarts. Monitoring and paging pause for a few seconds. Your data is not changed.'}, async () => {
await api('POST', V1 + '/system/update', {}); wait(u.current); })}, `Update to ${u.latest}`) : null].filter(Boolean));
};
const wait = async from => {
actions.replaceChildren(); status.textContent = 'Updating.';
for (let i = 0; i < 120; i++) {
await new Promise(r => setTimeout(r, 1500));
let s; try { s = await api('GET', V1 + '/system/update'); } catch (e) { detail.textContent = 'Restarting.'; continue; }
if (s.current !== from) { toast(`Updated to ${s.current}.`); return refresh(); }
if (s.failed) { toast(s.failed, true); return paint(s); }
detail.textContent = s.line || s.stage;
}
toast('Sandbar did not come back in time. Check the server.', true);
};
paint(u);
return card('Software update', h('div', {class:'stack'}, status, detail, actions));
}
views.account = async () => { views.account = async () => {
const keys = await api('GET', V1 + '/apikeys'), pw = h('input', {type:'password', autocomplete:'new-password'}); const keys = await api('GET', V1 + '/apikeys'), pw = h('input', {type:'password', autocomplete:'new-password'});
const upd = S.me.role === 'admin' ? await api('GET', V1 + '/system/update').catch(() => null) : null;
S.headerAction = null; S.headerAction = null;
return h('div', {}, card('Password', h('div', {class:'stack'}, field('New password', pw, 'At least 10 characters. You are signed out on other devices.'), return h('div', {}, card('Password', h('div', {class:'stack'}, field('New password', pw, 'At least 10 characters. You are signed out on other devices.'),
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:act(async () => { await api('POST', `${V1}/users/${S.me.id}/password`, {password:pw.value}); pw.value = ''; toast('Password changed. Sign in again.'); S.me = null; boot(); })}, 'Change password')))), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:act(async () => { await api('POST', `${V1}/users/${S.me.id}/password`, {password:pw.value}); pw.value = ''; toast('Password changed. Sign in again.'); S.me = null; boot(); })}, 'Change password')))),
@@ -766,7 +887,7 @@ views.account = async () => {
h('button', {class:'ghost', onclick:() => { const n = h('input'); dialogForm('Create an API key', null, [field('Name', n, 'Where you will use it, such as "prometheus".')], 'Create key', async () => { h('button', {class:'ghost', onclick:() => { const n = h('input'); dialogForm('Create an API key', null, [field('Name', n, 'Where you will use it, such as "prometheus".')], 'Create key', async () => {
const k = await api('POST', V1 + '/apikeys', {name:n.value}); refresh(); const k = await api('POST', V1 + '/apikeys', {name:n.value}); refresh();
const done = modal('Copy your API key', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'This is the only time it is shown.'), cmdBox(k.token), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }); }}, icon('add', 16), 'Create API key')), const done = modal('Copy your API key', h('div', {class:'stack'}, h('p', {class:'dim', style:'margin:0'}, 'This is the only time it is shown.'), cmdBox(k.token), h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done')))); }); }}, icon('add', 16), 'Create API key')),
card('Your personal topic', h('p', {class:'dim'}, 'Subscribe to this topic in the ntfy app, or in a browser on the Topics page, to be paged when an escalation policy reaches you.'), cmdBox(S.me.topic))); card('Your personal topic', h('p', {class:'dim'}, 'Subscribe to this topic in the ntfy app, or in a browser on the Topics page, to be paged when an escalation policy reaches you.'), cmdBox(S.me.topic)), upd ? updateCard(upd) : null);
}; };
boot(); boot();
+66
View File
@@ -0,0 +1,66 @@
/* Sandbar website analytics. Add to a page:
<script defer src="https://sandbar.example.com/script.js" data-website="WEBSITE_ID"></script>
It sets no cookies and stores nothing in the browser. It sends the page path, the referrer, the
language and the browser's own user agent, and it honors Do Not Track.
Options on the tag: data-hash (count #hash changes as pages), data-auto="false" (send nothing
until you call sandbar.view() yourself). Custom events: sandbar.track("signup", {plan: "pro"}). */
(function () {
var tag = document.currentScript;
if (!tag) return;
var site = tag.getAttribute('data-website');
if (!site) return;
var base = tag.src.replace(/\/script\.js(\?.*)?$/, '');
var dnt = navigator.doNotTrack === '1' || window.doNotTrack === '1' || navigator.msDoNotTrack === '1';
var withHash = tag.hasAttribute('data-hash');
var last = '';
var first = true;
function send(body) {
if (dnt) return;
body.website = site;
body.hostname = location.hostname;
body.language = navigator.language || '';
var json = JSON.stringify(body);
// text/plain keeps this a "simple" cross-origin request, so no preflight is needed
try {
if (navigator.sendBeacon && navigator.sendBeacon(base + '/api/send', new Blob([json], { type: 'text/plain' }))) return;
} catch (e) { /* fall through to fetch */ }
try {
fetch(base + '/api/send', { method: 'POST', body: json, headers: { 'Content-Type': 'text/plain' }, keepalive: true, mode: 'cors', credentials: 'omit' });
} catch (e) { /* analytics must never break the page */ }
}
function view() {
var path = location.pathname + (withHash ? location.hash : '');
if (path === last) return;
last = path;
send({ path: path, referrer: first ? document.referrer : '' });
first = false;
}
function hook(name) {
var orig = history[name];
if (!orig) return;
history[name] = function () {
var r = orig.apply(this, arguments);
setTimeout(view, 0);
return r;
};
}
hook('pushState');
hook('replaceState');
window.addEventListener('popstate', function () { setTimeout(view, 0); });
if (withHash) window.addEventListener('hashchange', function () { setTimeout(view, 0); });
window.sandbar = {
view: view,
track: function (name, data) {
if (!name) return;
send({ path: location.pathname, event: String(name).slice(0, 80), data: data });
}
};
if (tag.getAttribute('data-auto') === 'false') return;
if (document.readyState === 'complete') view();
else window.addEventListener('load', view);
})();
+55 -7
View File
@@ -45,6 +45,17 @@
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; } .card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; }
.row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); } .row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); }
.row:last-child { border-bottom:0; } .row:last-child { border-bottom:0; }
.svc { padding:var(--s3) var(--s4) var(--s4); border-bottom:var(--border-w) solid var(--border); }
.svc:last-child { border-bottom:0; }
.svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; }
.bars { display:flex; gap:2px; height:32px; margin-top:var(--s3); }
.bar { flex:1; min-width:0; border-radius:2px; background:var(--neutral-bg); }
.bar.ok { background:var(--success); } .bar.warn { background:var(--warning); } .bar.bad { background:var(--danger); }
.bar:hover { opacity:.7; }
.axis { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
.tip { position:fixed; z-index:10; pointer-events:none; max-width:240px; padding:var(--s2) var(--s3); border-radius:var(--radius); background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); font-size:var(--text-sm); }
.tip strong { display:block; }
@media (max-width:480px) { .bars { gap:1px; } }
.badge { display:inline-flex; align-items:center; padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; } .badge { display:inline-flex; align-items:center; padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
.badge-success { background:var(--success-bg); color:var(--success); } .badge-success { background:var(--success-bg); color:var(--success); }
.badge-warning { background:var(--warning-bg); color:var(--warning); } .badge-warning { background:var(--warning-bg); color:var(--warning); }
@@ -56,6 +67,7 @@
</style> </style>
</head> </head>
<body> <body>
<div class="tip" id="tip" role="presentation" hidden></div>
<main> <main>
<h1>Status</h1> <h1>Status</h1>
<div id="banner" class="banner" role="status">Loading</div> <div id="banner" class="banner" role="status">Loading</div>
@@ -72,6 +84,48 @@ const label = { operational: 'All systems are operational', degraded: 'Some syst
const statusBadge = s => s === 'up' ? el('span', 'badge badge-success', 'Operational') : s === 'down' ? el('span', 'badge badge-danger', 'Down') : el('span', 'badge', 'Waiting for first check'); const statusBadge = s => s === 'up' ? el('span', 'badge badge-success', 'Operational') : s === 'down' ? el('span', 'badge badge-danger', 'Down') : el('span', 'badge', 'Waiting for first check');
const incStatus = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' }; const incStatus = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' };
// One bar per UTC day, oldest first. A day is green with no failed checks, amber when at least 99% passed,
// and red below that. Color is never the only signal: the tooltip and the label say it in words.
const dayFmt = new Intl.DateTimeFormat(undefined, {month:'short', day:'numeric', year:'numeric', timeZone:'UTC'});
const dayClass = ([c, f]) => !c ? '' : !f ? 'ok' : (c - f) / c >= 0.99 ? 'warn' : 'bad';
function dayText([c, f], i, n, today) {
const d = new Date(today + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - (n - 1 - i));
if (!c) return [dayFmt.format(d), 'No data'];
const up = ((c - f) * 100 / c).toFixed(2) + '% uptime';
return [dayFmt.format(d), f ? up + ', ' + f.toLocaleString() + ' failed of ' + c.toLocaleString() + ' checks' : up];
}
const tip = $('tip');
function showTip(e, lines) {
tip.replaceChildren(el('strong', '', lines[0]), document.createTextNode(lines[1]));
tip.hidden = false;
const w = tip.offsetWidth, x = Math.min(Math.max(8, e.clientX - w / 2), innerWidth - w - 8);
tip.style.left = x + 'px'; tip.style.top = Math.max(8, e.clientY - tip.offsetHeight - 14) + 'px';
}
function service(m, today) {
const box = el('div', 'svc'), top = el('div', 'top');
top.append(el('span', '', m.name));
const right = el('span', 'small dim num');
right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
top.append(right); box.append(top);
const days = m.days || [], bars = el('div', 'bars');
const bad = days.filter(d => dayClass(d) === 'bad').length, warn = days.filter(d => dayClass(d) === 'warn').length;
bars.setAttribute('role', 'img');
bars.setAttribute('aria-label', m.name + ', the last ' + days.length + ' days: ' + (m.uptime >= 0 ? m.uptime.toFixed(2) + '% uptime' : 'no checks yet') +
(bad || warn ? ', ' + bad + ' days with an outage and ' + warn + ' with short failures' : ', no failures'));
days.forEach((d, i) => {
const b = el('span', 'bar ' + dayClass(d));
const lines = dayText(d, i, days.length, today);
b.addEventListener('pointerenter', e => showTip(e, lines));
b.addEventListener('pointermove', e => showTip(e, lines));
b.addEventListener('pointerleave', () => { tip.hidden = true; });
bars.append(b);
});
const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true');
axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today'));
if (days.length) box.append(bars, axis);
return box;
}
async function load() { async function load() {
let d; let d;
try { d = await (await fetch('/status.json')).json(); } catch (e) { $('banner').textContent = 'The status could not be loaded. Try again in a moment.'; return; } try { d = await (await fetch('/status.json')).json(); } catch (e) { $('banner').textContent = 'The status could not be loaded. Try again in a moment.'; return; }
@@ -80,13 +134,7 @@ async function load() {
const sv = $('services'); sv.replaceChildren(); const sv = $('services'); sv.replaceChildren();
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.')); if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
for (const m of d.monitors) { for (const m of d.monitors) sv.append(service(m, d.today));
const r = el('div', 'row');
r.append(el('span', '', m.name));
const right = el('span', 'small dim num');
right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : ''));
r.append(right); sv.append(r);
}
const ic = $('incidents'); ic.replaceChildren(); const ic = $('incidents'); ic.replaceChildren();
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.')); if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));