Add website analytics
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages, referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ea4fe5289
commit
b4fd31a182
13 files changed
+843
-22
No files matched your search
@@ -30,9 +30,14 @@ SANDBAR_MASTER_KEY=
|
|||||||
#SANDBAR_UPDATE_SERVER=https://git.veillax.com
|
#SANDBAR_UPDATE_SERVER=https://git.veillax.com
|
||||||
#SANDBAR_UPDATE_REPO=Veillax/sandbar
|
#SANDBAR_UPDATE_REPO=Veillax/sandbar
|
||||||
|
|
||||||
|
# Behind a reverse proxy, take the client address from X-Forwarded-For. Needed for website analytics and
|
||||||
|
# the sign-in limit to tell visitors apart. Only set it when the proxy is the only way to reach Sandbar.
|
||||||
|
#SANDBAR_TRUST_PROXY=1
|
||||||
|
|
||||||
# How long topic messages and monitor results are kept, in days.
|
# How long topic messages and monitor results are kept, in days.
|
||||||
#SANDBAR_MESSAGE_DAYS=30
|
#SANDBAR_MESSAGE_DAYS=30
|
||||||
#SANDBAR_CHECK_DAYS=30
|
#SANDBAR_CHECK_DAYS=30
|
||||||
|
#SANDBAR_ANALYTICS_DAYS=365
|
||||||
|
|
||||||
# Outgoing email, for email channels and for paging people by email.
|
# Outgoing email, for email channels and for paging people by email.
|
||||||
#SANDBAR_SMTP_HOST=smtp.example.com
|
#SANDBAR_SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
Self-hosted notifications, monitoring, on-call and incident response in one small program.
|
Self-hosted notifications, monitoring, on-call and incident response in one small program.
|
||||||
One binary, one SQLite file, and everything is done in the web UI.
|
One binary, one SQLite file, and everything is done in the web UI.
|
||||||
|
|
||||||
Sandbar covers the ground of four tools:
|
Sandbar covers the ground of five tools:
|
||||||
|
|
||||||
| Area | Like | What it does |
|
| Area | Like | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Topics** | ntfy | Publish a message to a topic with one HTTP request. Subscribe in the browser, with `curl`, or with the ntfy phone app |
|
| **Topics** | ntfy | Publish a message to a topic with one HTTP request. Subscribe in the browser, with `curl`, or with the ntfy phone app |
|
||||||
| **Monitors** | Tianji, Uptime Kuma | Website, port, DNS and certificate checks, heartbeats for cron jobs, and a public status page |
|
| **Monitors** | Tianji, Uptime Kuma | Website, port, DNS and certificate checks, heartbeats for cron jobs, and a public status page |
|
||||||
|
| **Websites** | Tianji, Plausible, Umami | Page views, visitors, referrers, browsers, countries and custom events for your own sites, with no cookies and no personal data |
|
||||||
| **On-call** | Opsgenie | Rotating schedules, escalation policies, and alerts that are acknowledged, snoozed or resolved |
|
| **On-call** | Opsgenie | Rotating schedules, escalation policies, and alerts that are acknowledged, snoozed or resolved |
|
||||||
| **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem |
|
| **Incidents** | incident.io | Declare, assign, update and close an incident, with a timeline and a post-mortem |
|
||||||
|
|
||||||
@@ -112,7 +113,8 @@ new releases itself, in Docker and as a plain binary, on Linux and macOS.
|
|||||||
### HTTPS and a reverse proxy
|
### 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
|
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 {
|
sandbar.example.com {
|
||||||
@@ -200,6 +202,35 @@ in a small per-day table, so the bars go back 90 days even though raw check resu
|
|||||||
Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that
|
Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that
|
||||||
monitor, so a user who can add a monitor cannot use Sandbar to scan your network.
|
monitor, so a user who can add a monitor cannot use Sandbar to scan your network.
|
||||||
|
|
||||||
|
## Websites (analytics)
|
||||||
|
|
||||||
|
Add a website on the Websites page, then paste its one-line tracking code into the head of your pages:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script defer src="https://sandbar.example.com/script.js" data-website="WEBSITE_ID"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The page for each website shows visitors, page views, visits, bounce rate and who is online now, a chart over the last
|
||||||
|
24 hours, 7, 30 or 90 days, and top lists for pages, referrers, browsers, operating systems, devices, countries,
|
||||||
|
languages and events.
|
||||||
|
|
||||||
|
- **Single-page apps** are counted as people move between pages. Add `data-hash` to count `#hash` changes as pages,
|
||||||
|
or `data-auto="false"` to send nothing until your code calls `sandbar.view()`.
|
||||||
|
- **Events:** call `sandbar.track("signup", { plan: "pro" })` from your code.
|
||||||
|
- **Private by design:** no cookies, nothing stored in the browser, no IP address or user agent kept (only the labels
|
||||||
|
"Chrome", "macOS", "Mobile" and so on), and nothing is sent when the visitor has Do Not Track on. A visitor is a hash
|
||||||
|
of the address, the browser and the website with a secret and the date, so the same person is one visitor for a day
|
||||||
|
and cannot be followed beyond it. Query strings are dropped from paths and only the referrer's host is kept.
|
||||||
|
- **Domain:** a website's optional domain makes Sandbar ignore visits from other hosts. Bots, and anything sending
|
||||||
|
more than 120 hits a minute from one address, are ignored.
|
||||||
|
- **Countries** come from the `CF-IPCountry` or `CloudFront-Viewer-Country` header, so they appear only behind
|
||||||
|
Cloudflare or CloudFront. Sandbar bundles no location database. Without the header, visits count as Unknown.
|
||||||
|
- **Behind a reverse proxy,** set `SANDBAR_TRUST_PROXY=1` so the client address comes from `X-Forwarded-For`. Without
|
||||||
|
it every visitor looks like the proxy. Only set it when the proxy is the only way to reach Sandbar.
|
||||||
|
- Hits are kept for `SANDBAR_ANALYTICS_DAYS` (default 365).
|
||||||
|
|
||||||
|
Not included: funnels, goals, session replay, a public share link, and comparison with the previous period.
|
||||||
|
|
||||||
## On-call
|
## On-call
|
||||||
|
|
||||||
- A **schedule** rotates through people, handing over every N hours from a start time. **Overrides** cover a
|
- A **schedule** rotates through people, handing over every N hours from a start time. **Overrides** cover a
|
||||||
@@ -256,7 +287,7 @@ than their retention are pruned hourly. Back up by copying the data folder while
|
|||||||
- One instance, one organization. SQLite means no clustering.
|
- One instance, one organization. SQLite means no clustering.
|
||||||
- No ICMP ping monitor, no SMS or voice, and no two-factor or single sign-on yet.
|
- No ICMP ping monitor, no SMS or voice, and no two-factor or single sign-on yet.
|
||||||
- Sandbar serves plain HTTP. Put it behind HTTPS and set `-url`, or keep it on a private network.
|
- Sandbar serves plain HTTP. Put it behind HTTPS and set `-url`, or keep it on a private network.
|
||||||
- Sign-in attempts are limited to 10 per 10 minutes per address.
|
- Sign-in attempts are limited to 10 per 10 minutes per address (the client address, see `SANDBAR_TRUST_PROXY`).
|
||||||
- Tests: `go test ./...`. Style: `node scripts/lint-design.mjs` (see `docs/design.md`).
|
- Tests: `go test ./...`. Style: `node scripts/lint-design.mjs` (see `docs/design.md`).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ func main() {
|
|||||||
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
|
MetricsToken: os.Getenv("SANDBAR_METRICS_TOKEN"),
|
||||||
MessageDays: atoi("SANDBAR_MESSAGE_DAYS"),
|
MessageDays: atoi("SANDBAR_MESSAGE_DAYS"),
|
||||||
CheckDays: atoi("SANDBAR_CHECK_DAYS"),
|
CheckDays: atoi("SANDBAR_CHECK_DAYS"),
|
||||||
|
HitDays: atoi("SANDBAR_ANALYTICS_DAYS"),
|
||||||
|
TrustProxy: os.Getenv("SANDBAR_TRUST_PROXY") == "1",
|
||||||
Version: version,
|
Version: version,
|
||||||
AutoUpdate: *autoUpdate,
|
AutoUpdate: *autoUpdate,
|
||||||
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
|
UpdateServer: os.Getenv("SANDBAR_UPDATE_SERVER"),
|
||||||
|
|||||||
+4
-2
@@ -28,15 +28,17 @@ The helpers at the top of the script in `index.html` are the same as Hammerhead'
|
|||||||
| `rowList(addLabel, make, initial)` | A list the person adds to and removes from, such as people in a rotation or steps in a policy |
|
| `rowList(addLabel, make, initial)` | A list the person adds to and removes from, such as people in a rotation or steps in a policy |
|
||||||
| `timeline(events)` | Dated entries for an alert or an incident |
|
| `timeline(events)` | Dated entries for an alert or an incident |
|
||||||
|
|
||||||
## Status page bars
|
## 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.
|
* **Uptime bars** on the public page are one bar per UTC day, 90 of them. Green is no failures, amber is at least 99% passing, red is below that, grey is no data. Color is never the only signal: the row carries a text status badge and the uptime percentage, each bar's tooltip says the date and the numbers in words, and the whole strip has an `aria-label` summary.
|
||||||
|
* **Analytics charts** are plain bars made of tokens, with two series (visitors in the accent, page views in a lighter mix of it), a legend with text, a native tooltip per bar, and an `aria-label` that gives the totals. There are no chart libraries.
|
||||||
|
* **Top lists** are tables with a proportional bar behind the name, so the numbers stay readable without the bar.
|
||||||
|
|
||||||
## Icons
|
## Icons
|
||||||
|
|
||||||
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
|
Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`.
|
||||||
|
|
||||||
Approved names: `sandbar`, `alerts`, `incidents`, `oncall`, `monitors`, `topics`, `channels`, `status`, `users`, `account`, `out`, `menu`, `close`, `add`, `check`, `back`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
|
Approved names: `sandbar`, `alerts`, `incidents`, `oncall`, `monitors`, `websites`, `topics`, `channels`, `status`, `users`, `account`, `out`, `menu`, `close`, `add`, `check`, `back`, `light`, `dark`, `alert`. Add a name here and in `ICONS` together.
|
||||||
|
|
||||||
## Patterns
|
## Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /status.json", s.statusJSON)
|
mux.HandleFunc("GET /status.json", s.statusJSON)
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
|
||||||
mux.HandleFunc("GET /metrics", s.metrics)
|
mux.HandleFunc("GET /metrics", s.metrics)
|
||||||
|
mux.HandleFunc("GET /script.js", s.serveTracker)
|
||||||
|
mux.HandleFunc("POST /api/send", s.collect)
|
||||||
|
mux.HandleFunc("OPTIONS /api/send", s.collect)
|
||||||
mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat)
|
mux.HandleFunc("GET /api/v1/hb/{token}", s.heartbeat)
|
||||||
mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat)
|
mux.HandleFunc("POST /api/v1/hb/{token}", s.heartbeat)
|
||||||
|
|
||||||
@@ -105,6 +108,9 @@ func (s *Server) Handler() http.Handler {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, nil)
|
}, nil)
|
||||||
|
s.crud(mux, "websites", "website", true, func() any { return &Website{} }, prepareWebsite, nil)
|
||||||
|
mux.HandleFunc("GET /api/v1/websites/{id}/stats", s.auth(false, s.websiteStatsHandler))
|
||||||
|
mux.HandleFunc("GET /api/v1/website-summary", s.auth(false, s.websiteSummary))
|
||||||
mux.HandleFunc("GET /api/v1/system/update", s.auth(true, s.updateStatus))
|
mux.HandleFunc("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/system/update", s.auth(true, s.updateApply))
|
||||||
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
|
mux.HandleFunc("POST /api/v1/channels/{id}/test", s.auth(true, s.testChannel))
|
||||||
@@ -349,6 +355,9 @@ func (s *Server) crud(mux *http.ServeMux, path, kind string, adminWrite bool, ne
|
|||||||
if kind == "monitor" {
|
if kind == "monitor" {
|
||||||
s.deleteMonitorData(id)
|
s.deleteMonitorData(id)
|
||||||
}
|
}
|
||||||
|
if kind == "website" {
|
||||||
|
s.deleteWebsiteData(id)
|
||||||
|
}
|
||||||
fail2(w, s.st.del(kind, id))
|
fail2(w, s.st.del(kind, id))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -144,19 +143,11 @@ func (s *Server) noteFailure(ip string) {
|
|||||||
s.loginMu.Unlock()
|
s.loginMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
|
||||||
h, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return r.RemoteAddr
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
var errThrottled = errors.New("too many attempts, try again in a few minutes")
|
var errThrottled = errors.New("too many attempts, try again in a few minutes")
|
||||||
|
|
||||||
// login checks credentials and starts a session.
|
// login checks credentials and starts a session.
|
||||||
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
|
func (s *Server) login(w http.ResponseWriter, r *http.Request, email, password string) (*User, error) {
|
||||||
ip := clientIP(r)
|
ip := s.realIP(r)
|
||||||
if s.throttled(ip) {
|
if s.throttled(ip) {
|
||||||
return nil, errThrottled
|
return nil, errThrottled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestUptimeBars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The rollup survives pruning the raw checks.
|
// The rollup survives pruning the raw checks.
|
||||||
s.st.prune(clk.Now().AddDate(0, 0, 60), 30, 1)
|
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} {
|
if got := s.dailyUptime(m.ID, 5, clk.Now()); got[2] != [2]int{4, 1} {
|
||||||
t.Fatalf("rollup lost after pruning: %v", got)
|
t.Fatalf("rollup lost after pruning: %v", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,10 @@ type Options struct {
|
|||||||
MasterKey string
|
MasterKey string
|
||||||
MetricsToken string
|
MetricsToken string
|
||||||
SMTP SMTPConfig
|
SMTP SMTPConfig
|
||||||
MessageDays int // how long topic messages are kept (default 30)
|
MessageDays int // how long topic messages are kept (default 30)
|
||||||
CheckDays int // how long monitor results are kept (default 30)
|
CheckDays int // how long monitor results are kept (default 30)
|
||||||
|
HitDays int // how long website analytics are kept (default 365)
|
||||||
|
TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy
|
||||||
|
|
||||||
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
|
// 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
|
// ("dev") never updates. Restart is called after a new binary is installed and should close
|
||||||
@@ -55,6 +57,10 @@ type Server struct {
|
|||||||
|
|
||||||
upd updateState
|
upd updateState
|
||||||
|
|
||||||
|
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
|
||||||
|
anaSecret string
|
||||||
|
anaTry map[string][]time.Time
|
||||||
|
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
@@ -66,7 +72,10 @@ func New(opts Options, st *Store) *Server {
|
|||||||
if opts.CheckDays <= 0 {
|
if opts.CheckDays <= 0 {
|
||||||
opts.CheckDays = 30
|
opts.CheckDays = 30
|
||||||
}
|
}
|
||||||
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, stop: make(chan struct{})}
|
if opts.HitDays <= 0 {
|
||||||
|
opts.HitDays = 365
|
||||||
|
}
|
||||||
|
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, anaTry: map[string][]time.Time{}, stop: make(chan struct{})}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) now() time.Time {
|
func (s *Server) now() time.Time {
|
||||||
@@ -80,7 +89,7 @@ func (s *Server) now() time.Time {
|
|||||||
func (s *Server) Start() {
|
func (s *Server) Start() {
|
||||||
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
|
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
|
||||||
s.every(5*time.Second, func() { s.escalate(s.now()) })
|
s.every(5*time.Second, func() { s.escalate(s.now()) })
|
||||||
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays) })
|
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays, s.opts.HitDays) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Close() {
|
func (s *Server) Close() {
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ 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.
|
-- 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 (
|
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));
|
monitor_id TEXT NOT NULL, day TEXT NOT NULL, checks INTEGER NOT NULL, fails INTEGER NOT NULL, PRIMARY KEY (monitor_id, day));
|
||||||
|
-- Website analytics: one row per page view or event. No IP address or user agent is kept, only labels.
|
||||||
|
CREATE TABLE IF NOT EXISTS hits (
|
||||||
|
website TEXT NOT NULL, time INTEGER NOT NULL, visitor TEXT NOT NULL, event TEXT NOT NULL, path TEXT NOT NULL,
|
||||||
|
referrer TEXT NOT NULL, browser TEXT NOT NULL, os TEXT NOT NULL, device TEXT NOT NULL, country TEXT NOT NULL,
|
||||||
|
lang TEXT NOT NULL, data TEXT NOT NULL);
|
||||||
|
CREATE INDEX IF NOT EXISTS hits_site ON hits(website, time);
|
||||||
CREATE TABLE IF NOT EXISTS mstate (
|
CREATE TABLE IF NOT EXISTS mstate (
|
||||||
monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL,
|
monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL,
|
||||||
last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL);
|
last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL);
|
||||||
@@ -246,7 +252,8 @@ func (s *Store) events(scope, ref string) []Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// prune deletes data past its retention.
|
// prune deletes data past its retention.
|
||||||
func (s *Store) prune(now time.Time, messageDays, checkDays int) {
|
func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
|
||||||
|
s.db.Exec(`DELETE FROM hits WHERE time < ?`, now.AddDate(0, 0, -hitDays).Unix())
|
||||||
s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix())
|
s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix())
|
||||||
s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix())
|
s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix())
|
||||||
s.db.Exec(`DELETE FROM daily WHERE day < ?`, now.UTC().AddDate(0, 0, -400).Format("2006-01-02"))
|
s.db.Exec(`DELETE FROM daily WHERE day < ?`, now.UTC().AddDate(0, 0, -400).Format("2006-01-02"))
|
||||||
|
|||||||
@@ -227,6 +227,28 @@
|
|||||||
.feed .empty { margin:0; }
|
.feed .empty { margin:0; }
|
||||||
.msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); }
|
.msg { border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s2) var(--s3); display:flex; flex-direction:column; gap:var(--s1); }
|
||||||
.msg .tag { margin-right:var(--s1); }
|
.msg .tag { margin-right:var(--s1); }
|
||||||
|
/* website analytics */
|
||||||
|
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(104px,1fr)); gap:var(--s3); margin-bottom:var(--s4); }
|
||||||
|
.stat { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); padding:var(--s3) var(--s4); }
|
||||||
|
.stat .n { font-size:var(--text-xl); font-weight:600; font-variant-numeric:tabular-nums; }
|
||||||
|
.stat .l { font-size:var(--text-sm); color:var(--text-muted); }
|
||||||
|
.chart { display:flex; align-items:flex-end; gap:2px; height:180px; margin-top:var(--s3); }
|
||||||
|
.chart .col { position:relative; flex:1; min-width:0; height:100%; display:flex; align-items:flex-end; }
|
||||||
|
.chart .col:hover { background:var(--surface-2); }
|
||||||
|
.chart .v, .chart .u { position:absolute; left:0; right:0; bottom:0; border-radius:2px 2px 0 0; }
|
||||||
|
.chart .v { background:color-mix(in srgb, var(--accent) 35%, var(--surface)); }
|
||||||
|
.chart .u { background:var(--accent); }
|
||||||
|
.axis2 { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); }
|
||||||
|
.legend { display:flex; gap:var(--s4); font-size:var(--text-sm); color:var(--text-muted); flex-wrap:wrap; }
|
||||||
|
.legend i { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:var(--s1); vertical-align:baseline; }
|
||||||
|
.cols { display:grid; grid-template-columns:repeat(auto-fit,minmax(300px,1fr)); gap:var(--s4); margin-top:var(--s4); }
|
||||||
|
.cols .card { margin:0; }
|
||||||
|
.top { width:100%; border-collapse:collapse; font-size:var(--text-sm); }
|
||||||
|
.top td { padding:var(--s1) var(--s2); }
|
||||||
|
.top td:first-child { background:linear-gradient(to right, var(--accent-soft) var(--w), transparent var(--w)); overflow-wrap:anywhere; }
|
||||||
|
.top td.num { text-align:right; white-space:nowrap; width:1%; }
|
||||||
|
.top th { text-align:right; font-weight:500; color:var(--text-muted); padding:var(--s1) var(--s2); }
|
||||||
|
.top th:first-child { text-align:left; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -360,6 +382,7 @@ const ICONS = {
|
|||||||
incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
incidents:'<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/>',
|
||||||
oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
oncall:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||||
monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
monitors:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
|
||||||
|
websites:'<path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/>',
|
||||||
topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
topics:'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||||
channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>',
|
channels:'<path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/>',
|
||||||
status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
status:'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||||
@@ -420,6 +443,7 @@ const PAGES = {
|
|||||||
incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'],
|
incidents: ['Incidents', 'Coordinate the response when something is wrong, and keep a record of what happened.'],
|
||||||
oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'],
|
oncall: ['On-call', 'Who is responsible right now, and who is told next when nobody answers.'],
|
||||||
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
|
monitors: ['Monitors', 'Checks that tell you when a website, port, certificate or scheduled job stops working.'],
|
||||||
|
websites: ['Websites', 'Count visitors and page views on your sites, without cookies or personal data.'],
|
||||||
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
|
topics: ['Topics', 'Publish and subscribe to messages with plain HTTP requests. Phones can use the ntfy app.'],
|
||||||
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
|
channels: ['Channels', 'Email addresses and webhooks that receive every alert and incident update.'],
|
||||||
users: ['People', 'Everyone who can sign in, and how to reach them.'],
|
users: ['People', 'Everyone who can sign in, and how to reach them.'],
|
||||||
@@ -427,7 +451,7 @@ const PAGES = {
|
|||||||
};
|
};
|
||||||
const NAV = [
|
const NAV = [
|
||||||
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
|
['Respond', [['alerts', 'Alerts', 'alerts'], ['incidents', 'Incidents', 'incidents'], ['oncall', 'On-call', 'oncall']]],
|
||||||
['Observe', [['monitors', 'Monitors', 'monitors'], ['topics', 'Topics', 'topics']]],
|
['Observe', [['monitors', 'Monitors', 'monitors'], ['websites', 'Websites', 'websites'], ['topics', 'Topics', 'topics']]],
|
||||||
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
['Set up', [['channels', 'Channels', 'channels'], ['users', 'People', 'users']]],
|
||||||
];
|
];
|
||||||
function route() {
|
function route() {
|
||||||
@@ -754,6 +778,75 @@ function passwordDialog(u) {
|
|||||||
dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); });
|
dialogForm('Set a password for ' + u.name, 'They are signed out everywhere.', [field('New password', pw, 'At least 10 characters')], 'Set password', async () => { await api('POST', `${V1}/users/${u.id}/password`, {password:pw.value}); toast('Password changed.'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- websites (analytics) ----------
|
||||||
|
const RANGES = [['24h', 'Last 24 hours'], ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ['90d', 'Last 90 days']];
|
||||||
|
const num = n => Number(n || 0).toLocaleString();
|
||||||
|
function regionName(code) { try { return new Intl.DisplayNames(undefined, {type:'region'}).of(code) || code; } catch (e) { return code; } }
|
||||||
|
function languageName(code) { try { return new Intl.DisplayNames(undefined, {type:'language'}).of(code) || code; } catch (e) { return code; } }
|
||||||
|
|
||||||
|
views.websites = async () => {
|
||||||
|
if (S.arg) return websiteDetail(S.arg);
|
||||||
|
const admin = S.me.role === 'admin';
|
||||||
|
S.headerAction = admin ? ['Add website', () => websiteDialog()] : null;
|
||||||
|
const [sites, sum] = await Promise.all([api('GET', V1 + '/websites'), api('GET', V1 + '/website-summary')]);
|
||||||
|
if (!sites.length) return emptyState('websites', 'No websites yet', 'Add a website, then paste one line of code into its pages. Visitors and page views show up here. No cookies are set and no personal data is kept.',
|
||||||
|
admin ? h('button', {class:'btn', onclick:() => websiteDialog()}, 'Add website') : null);
|
||||||
|
return table(['Website', 'Visitors, last 24 hours', 'Page views, last 24 hours', ''], sites.map(w => { const s = sum[w.id] || {};
|
||||||
|
return h('tr', {}, h('td', {}, h('a', {href:'#websites/' + w.id}, w.name), w.domain ? h('div', {class:'small dim'}, w.domain) : null),
|
||||||
|
h('td', {class:'num'}, num(s.visitors)), h('td', {class:'num'}, num(s.views)),
|
||||||
|
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code'),
|
||||||
|
admin ? h('button', {class:'ghost', onclick:() => websiteDialog(w)}, 'Edit') : null,
|
||||||
|
admin ? h('button', {class:'ghost danger', onclick:ask({title:`Delete ${w.name}?`, body:'The website and all of its visits and events are removed. This cannot be undone.', confirm:'Delete website'}, async () => { await api('DELETE', `${V1}/websites/${w.id}`); toast('Website deleted.'); refresh(); })}, 'Delete') : null)));
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
function websiteDialog(w) {
|
||||||
|
const e = !!w, f = { name: h('input', {value:w ? w.name : ''}), domain: h('input', {value:w ? w.domain : '', placeholder:'example.com'}) };
|
||||||
|
dialogForm(e ? 'Edit ' + w.name : 'Add a website', null, [field('Name', f.name), field('Domain', f.domain, 'Optional. When set, visits from other domains are ignored, which stops others counting against your site.')], e ? 'Save changes' : 'Add website',
|
||||||
|
async () => { const saved = await api(e ? 'PUT' : 'POST', e ? `${V1}/websites/${w.id}` : V1 + '/websites', {...(w || {}), name:f.name.value, domain:f.domain.value}); toast(e ? 'Website saved.' : 'Website added.'); await refresh(); if (!e) trackingDialog(saved); });
|
||||||
|
}
|
||||||
|
function trackingDialog(w) {
|
||||||
|
const tag = `<script defer src="${origin()}/script.js" data-website="${w.id}"><\/script>`;
|
||||||
|
const done = modal('Tracking code for ' + w.name, h('div', {class:'stack'},
|
||||||
|
h('p', {class:'dim', style:'margin:0'}, 'Paste this inside the head of every page you want to count.'), cmdBox(tag),
|
||||||
|
h('p', {class:'dim', style:'margin:0'}, 'Single-page sites are counted as people move between pages. To count something else, such as a sign-up, call this from your code:'),
|
||||||
|
cmdBox('sandbar.track("signup", { plan: "pro" })'),
|
||||||
|
h('p', {class:'small dim', style:'margin:0'}, 'The script sets no cookies, keeps no IP address, and does nothing when the visitor has Do Not Track on. Behind a reverse proxy, set SANDBAR_TRUST_PROXY=1 so visitors are told apart.'),
|
||||||
|
h('div', {class:'actions form-actions'}, h('button', {class:'btn', onclick:() => done()}, 'Done'))));
|
||||||
|
}
|
||||||
|
async function websiteDetail(id) {
|
||||||
|
S.noHeader = true;
|
||||||
|
const range = S.siteRange || '7d';
|
||||||
|
const [sites, st] = await Promise.all([api('GET', V1 + '/websites'), api('GET', `${V1}/websites/${id}/stats?range=${range}`)]);
|
||||||
|
setTimeout(() => { S.noHeader = false; });
|
||||||
|
const w = sites.find(x => x.id === id);
|
||||||
|
if (!w) return emptyState('websites', 'That website no longer exists', 'It may have been deleted.', h('a', {class:'btn', href:'#websites'}, 'Back to websites'));
|
||||||
|
const tabs = h('div', {class:'tabs2', role:'group', 'aria-label':'Time range'}, RANGES.map(([k, l]) => h('button', {class:range === k ? 'on' : '', 'aria-pressed':range === k, onclick:() => { S.siteRange = k; refresh(); }}, l)));
|
||||||
|
const tile = (n, l) => h('div', {class:'stat'}, h('div', {class:'n'}, n), h('div', {class:'l'}, l));
|
||||||
|
const stats = h('div', {class:'stats'}, tile(num(st.visitors), 'Visitors'), tile(num(st.views), 'Page views'), tile(num(st.visits), 'Visits'),
|
||||||
|
tile(st.bounceRate >= 0 ? Math.round(st.bounceRate) + '%' : 'No data', 'Bounce rate'), tile(num(st.online), 'Online now'));
|
||||||
|
const max = Math.max(1, ...st.series.map(p => p.views));
|
||||||
|
const fmt = p => new Date(p.t * 1000).toLocaleString(undefined, st.bucket === 'hour' ? {month:'short', day:'numeric', hour:'numeric'} : {month:'short', day:'numeric', year:'numeric'});
|
||||||
|
const chart = h('div', {class:'chart', role:'img', 'aria-label':`${num(st.views)} page views from ${num(st.visitors)} visitors in the ${RANGES.find(r => r[0] === range)[1].toLowerCase()}`},
|
||||||
|
st.series.map(p => h('div', {class:'col', title:`${fmt(p)}: ${num(p.visitors)} visitors, ${num(p.views)} page views`},
|
||||||
|
h('div', {class:'v', style:`height:${p.views ? Math.max(2, p.views * 100 / max) : 0}%`}), h('div', {class:'u', style:`height:${p.visitors ? Math.max(2, p.visitors * 100 / max) : 0}%`}))));
|
||||||
|
const list = (title, rows, label, nameOf) => {
|
||||||
|
const top = Math.max(1, ...rows.map(r => r.views));
|
||||||
|
return h('div', {class:'card'}, h('h2', {}, title), rows.length ? h('table', {class:'top'}, h('thead', {}, h('tr', {}, h('th', {scope:'col'}, label), h('th', {scope:'col'}, 'Visitors'), h('th', {scope:'col'}, 'Views'))),
|
||||||
|
h('tbody', {}, rows.map(r => h('tr', {}, h('td', {style:`--w:${Math.round(r.views * 100 / top)}%`}, nameOf ? nameOf(r.name) : r.name), h('td', {class:'num'}, num(r.visitors)), h('td', {class:'num'}, num(r.views))))))
|
||||||
|
: h('p', {class:'dim', style:'margin:0'}, 'Nothing yet.'));
|
||||||
|
};
|
||||||
|
const nothing = !st.views && !st.events.length;
|
||||||
|
return h('div', {}, h('div', {class:'page-h'}, h('div', {}, h('a', {href:'#websites', class:'small'}, 'Back to websites'), h('h1', {}, w.name), h('p', {}, w.domain || 'Any domain')),
|
||||||
|
h('button', {class:'ghost', onclick:() => trackingDialog(w)}, 'Tracking code')),
|
||||||
|
tabs, h('div', {style:'height:16px'}), stats,
|
||||||
|
nothing ? emptyState('websites', 'No visits in this period', 'Paste the tracking code into your pages, then open one. The first visit appears within a few seconds.', h('button', {class:'btn', onclick:() => trackingDialog(w)}, 'Show tracking code')) : h('div', {},
|
||||||
|
card('Visitors and page views', h('div', {class:'legend'}, h('span', {}, h('i', {style:'background:var(--accent)'}), 'Visitors'), h('span', {}, h('i', {style:'background:color-mix(in srgb, var(--accent) 35%, var(--surface))'}), 'Page views')), chart,
|
||||||
|
h('div', {class:'axis2', 'aria-hidden':'true'}, h('span', {}, fmt(st.series[0])), h('span', {}, 'Now'))),
|
||||||
|
h('div', {class:'cols'}, list('Pages', st.pages, 'Path'), list('Referrers', st.referrers, 'Source'), list('Browsers', st.browsers, 'Browser'), list('Operating systems', st.systems, 'System'),
|
||||||
|
list('Devices', st.devices, 'Device'), list('Countries', st.countries, 'Country', n => n.length === 2 ? regionName(n) : n), list('Languages', st.languages, 'Language', n => n.length <= 3 ? languageName(n) : n),
|
||||||
|
list('Events', st.events, 'Event'))));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- account ----------
|
// ---------- account ----------
|
||||||
// Administrators see which release is running and can install a newer one. The server restarts
|
// 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.
|
// into the new binary, so the page waits for it to come back.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user