Compare commits
4
Commits
7994ad492c
...
b4fd31a182
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4fd31a182 | ||
|
|
4ea4fe5289 | ||
|
|
73f45fb3ae | ||
|
|
955e01333d |
No files matched your search
@@ -23,9 +23,21 @@ SANDBAR_MASTER_KEY=
|
||||
# Leave empty and /metrics does not exist.
|
||||
#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.
|
||||
#SANDBAR_MESSAGE_DAYS=30
|
||||
#SANDBAR_CHECK_DAYS=30
|
||||
#SANDBAR_ANALYTICS_DAYS=365
|
||||
|
||||
# Outgoing email, for email channels and for paging people by email.
|
||||
#SANDBAR_SMTP_HOST=smtp.example.com
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -31,6 +31,10 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Shell scripts parse
|
||||
run: |
|
||||
for f in deploy/*.sh; do sh -n "$f"; done
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
name: Release
|
||||
|
||||
# Push a tag such as v0.1.0 to build and publish the container image:
|
||||
# git tag -a v0.1.0 -m "sandbar v0.1.0" && git push origin v0.1.0
|
||||
# Push a tag such as v0.2.0 to build and publish a release:
|
||||
# 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:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
image:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPRIVATE: git.veillax.com
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # release notes come from the history since the previous tag
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -28,19 +32,22 @@ jobs:
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
# The Dockerfile packages these binaries, so the arm64 image needs no emulation.
|
||||
- name: Build the binaries
|
||||
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
|
||||
- name: Build release archives
|
||||
run: sh deploy/build.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
# Optional secret REGISTRY_TOKEN (and variable REGISTRY_USER): an access token with
|
||||
# package write permission, for servers where the job token can't push.
|
||||
# Optional secret RELEASE_TOKEN: an access token that can write releases, for
|
||||
# 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
|
||||
if: ${{ vars.PUBLISH_DOCKER != 'false' }}
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
REGISTRY_USER: ${{ vars.REGISTRY_USER || github.actor }}
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
master.key
|
||||
.devdata/
|
||||
bin/
|
||||
dist/
|
||||
@@ -3,12 +3,13 @@
|
||||
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.
|
||||
|
||||
Sandbar covers the ground of four tools:
|
||||
Sandbar covers the ground of five tools:
|
||||
|
||||
| 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 |
|
||||
| **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 |
|
||||
| **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem |
|
||||
|
||||
@@ -47,6 +48,7 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
SANDBAR_URL: https://sandbar.example.com
|
||||
SANDBAR_AUTO_UPDATE: "1" # optional, see Updating below
|
||||
volumes:
|
||||
- sandbar-data:/data
|
||||
|
||||
@@ -62,12 +64,12 @@ docker compose up -d
|
||||
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 with `docker compose pull && docker compose up -d`. Your data stays in the volume. Copy the data folder
|
||||
before upgrading, since there is no migration tooling yet.
|
||||
- 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
|
||||
|
||||
Build `sandbar` (`go build -o bin/ ./cmd/sandbar`; releases only publish the container image), copy it to the server with a `.env` file, and run it
|
||||
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
|
||||
@@ -87,10 +89,32 @@ 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. Caddy needs one line:
|
||||
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 {
|
||||
@@ -136,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.
|
||||
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
|
||||
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.
|
||||
@@ -154,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
|
||||
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
|
||||
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
|
||||
|
||||
- A **schedule** rotates through people, handing over every N hours from a start time. **Overrides** cover a
|
||||
@@ -213,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.
|
||||
- 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.
|
||||
- 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`).
|
||||
|
||||
## License
|
||||
|
||||
+29
-2
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"git.veillax.com/Veillax/reef/dotenv"
|
||||
"git.veillax.com/Veillax/reef/secretbox"
|
||||
"git.veillax.com/Veillax/reef/selfupdate"
|
||||
"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")
|
||||
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")
|
||||
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")
|
||||
showVersion := flag.Bool("version", false, "print the version and exit")
|
||||
flag.Parse()
|
||||
@@ -58,6 +60,9 @@ func main() {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -72,20 +77,42 @@ func main() {
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var hs *http.Server
|
||||
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,
|
||||
URL: strings.TrimRight(*url, "/"),
|
||||
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
|
||||
MessageDays: atoi("SANDBAR_MESSAGE_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"),
|
||||
User: os.Getenv("SANDBAR_SMTP_USER"), Password: os.Getenv("SANDBAR_SMTP_PASSWORD"), From: env("SANDBAR_SMTP_FROM", "sandbar@localhost")},
|
||||
}, st)
|
||||
srv.BootstrapAdmin(os.Getenv("SANDBAR_ADMIN_EMAIL"), os.Getenv("SANDBAR_ADMIN_PASSWORD"))
|
||||
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() {
|
||||
log.Printf("sandbar %s listening on %s", version, *listen)
|
||||
if err := hs.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -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/"
|
||||
@@ -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
@@ -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 |
|
||||
| `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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
-15
@@ -25,6 +25,9 @@ func (s *Server) Handler() http.Handler {
|
||||
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 /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("POST /api/v1/hb/{token}", s.heartbeat)
|
||||
|
||||
@@ -105,6 +108,11 @@ func (s *Server) Handler() http.Handler {
|
||||
}
|
||||
return 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("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" {
|
||||
s.deleteMonitorData(id)
|
||||
}
|
||||
if kind == "website" {
|
||||
s.deleteWebsiteData(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())
|
||||
return
|
||||
}
|
||||
m.Event = "message"
|
||||
writeJSON(w, 200, m)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
topic := r.PathValue("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("X-Accel-Buffering", "no")
|
||||
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 {
|
||||
m.Event = "message"
|
||||
b, _ := json.Marshal(m)
|
||||
var err error
|
||||
if sse {
|
||||
@@ -560,21 +581,22 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
defer unsub()
|
||||
seen := map[string]bool{}
|
||||
if q := r.URL.Query().Get("since"); q != "" {
|
||||
since := int64(0)
|
||||
if q != "all" {
|
||||
since, _ = strconv.ParseInt(q, 10, 64)
|
||||
}
|
||||
for _, m := range s.MessagesSince(topic, since, 500) {
|
||||
for _, m := range s.replaySince(topic, q) {
|
||||
seen[m.ID] = true
|
||||
if !write(m) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.URL.Query().Get("poll") == "1" {
|
||||
return
|
||||
}
|
||||
if sse {
|
||||
fmt.Fprint(bw, ": connected\n\n")
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
} else {
|
||||
event("open")
|
||||
}
|
||||
keepalive := time.NewTicker(30 * time.Second)
|
||||
defer keepalive.Stop()
|
||||
@@ -594,15 +616,40 @@ func (s *Server) subscribeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
case <-keepalive.C:
|
||||
if sse {
|
||||
fmt.Fprint(bw, ": keepalive\n\n")
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
} else {
|
||||
fmt.Fprint(bw, "\n")
|
||||
event("keepalive")
|
||||
}
|
||||
bw.Flush()
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 ----------
|
||||
|
||||
func (s *Server) inboundAlert(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -764,10 +811,11 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
// statusJSON feeds the public status page: only monitors and incidents marked public.
|
||||
func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
|
||||
type mon struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Since int64 `json:"since"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Uptime float64 `json:"uptime"`
|
||||
Since int64 `json:"since"`
|
||||
Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today
|
||||
}
|
||||
type inc struct {
|
||||
Title string `json:"title"`
|
||||
@@ -781,14 +829,24 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
|
||||
Monitors []mon `json:"monitors"`
|
||||
Incidents []inc `json:"incidents"`
|
||||
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")
|
||||
for _, m := range mons {
|
||||
if !m.Public || !m.Enabled {
|
||||
continue
|
||||
}
|
||||
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" {
|
||||
out.Overall = "outage"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -144,19 +143,11 @@ func (s *Server) noteFailure(ip string) {
|
||||
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")
|
||||
|
||||
// login checks credentials and starts a session.
|
||||
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) {
|
||||
return nil, errThrottled
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,37 @@ func (s *Server) monitorState(id string) MonitorState {
|
||||
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) {
|
||||
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,
|
||||
@@ -309,6 +340,8 @@ func (s *Server) recordResult(m *Monitor, st MonitorState, now time.Time, dur ti
|
||||
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 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)
|
||||
key := "monitor:" + m.ID
|
||||
if ok {
|
||||
@@ -369,6 +402,7 @@ func (s *Server) Heartbeat(token string) bool {
|
||||
func (s *Server) deleteMonitorData(id string) {
|
||||
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 daily WHERE monitor_id=?`, id)
|
||||
s.ResolveAlertKey("monitor:"+id, "monitor deleted")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,8 +25,9 @@ import (
|
||||
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
Topic string `json:"topic"`
|
||||
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"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
|
||||
@@ -453,6 +453,56 @@ func TestAPIAuthSetupAndTopics(t *testing.T) {
|
||||
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.
|
||||
for _, reserved := range []string{"/api", "/status"} {
|
||||
r, _ := http.NewRequest("POST", ts.URL+reserved, strings.NewReader("x"))
|
||||
|
||||
@@ -23,8 +23,19 @@ type Options struct {
|
||||
MasterKey string
|
||||
MetricsToken string
|
||||
SMTP SMTPConfig
|
||||
MessageDays int // how long topic messages are kept (default 30)
|
||||
CheckDays int // how long monitor results are kept (default 30)
|
||||
MessageDays int // how long topic messages 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.
|
||||
Now func() time.Time
|
||||
@@ -44,6 +55,12 @@ type Server struct {
|
||||
loginMu sync.Mutex
|
||||
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{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
@@ -55,7 +72,10 @@ func New(opts Options, st *Store) *Server {
|
||||
if opts.CheckDays <= 0 {
|
||||
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 {
|
||||
@@ -69,7 +89,7 @@ func (s *Server) now() time.Time {
|
||||
func (s *Server) Start() {
|
||||
s.every(time.Second, func() { s.runDueMonitors(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() {
|
||||
|
||||
@@ -45,6 +45,15 @@ CREATE INDEX IF NOT EXISTS messages_topic ON messages(topic, time);
|
||||
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);
|
||||
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 (
|
||||
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);
|
||||
@@ -78,7 +87,19 @@ func OpenStore(dir string, box *secretbox.Box) (*Store, error) {
|
||||
db.Close()
|
||||
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() }
|
||||
@@ -231,9 +252,11 @@ func (s *Store) events(scope, ref string) []Event {
|
||||
}
|
||||
|
||||
// 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 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 alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -227,6 +227,28 @@
|
||||
.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 .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>
|
||||
</head>
|
||||
<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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
@@ -420,6 +443,7 @@ const PAGES = {
|
||||
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.'],
|
||||
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.'],
|
||||
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.'],
|
||||
@@ -427,7 +451,7 @@ const PAGES = {
|
||||
};
|
||||
const NAV = [
|
||||
['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']]],
|
||||
];
|
||||
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.'); });
|
||||
}
|
||||
|
||||
// ---------- 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 ----------
|
||||
// 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 () => {
|
||||
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;
|
||||
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')))),
|
||||
@@ -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 () => {
|
||||
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')),
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -45,6 +45,17 @@
|
||||
.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: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-success { background:var(--success-bg); color:var(--success); }
|
||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||
@@ -56,6 +67,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tip" id="tip" role="presentation" hidden></div>
|
||||
<main>
|
||||
<h1>Status</h1>
|
||||
<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 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() {
|
||||
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; }
|
||||
@@ -80,13 +134,7 @@ async function load() {
|
||||
|
||||
const sv = $('services'); sv.replaceChildren();
|
||||
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
|
||||
for (const m of d.monitors) {
|
||||
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);
|
||||
}
|
||||
for (const m of d.monitors) sv.append(service(m, d.today));
|
||||
|
||||
const ic = $('incidents'); ic.replaceChildren();
|
||||
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
|
||||
|
||||
Reference in new issue
Block a user