From 4ab66536b650e608039c0c3a205da897cb8bd2c5 Mon Sep 17 00:00:00 2001 From: Jason McCormick Date: Fri, 6 Nov 2026 19:10:44 -0500 Subject: new --- .gitignore | 1 + Makefile | 24 ++ README.md | 64 +++++ chrony.go | 175 +++++++++++++ deploy/gps-clock-dash.service | 38 +++ deploy/nginx-location.conf | 15 ++ go.mod | 3 + gpsd.go | 230 ++++++++++++++++++ main.go | 230 ++++++++++++++++++ web/app.js | 552 ++++++++++++++++++++++++++++++++++++++++++ web/index.html | 134 ++++++++++ web/style.css | 214 ++++++++++++++++ 12 files changed, 1680 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 chrony.go create mode 100644 deploy/gps-clock-dash.service create mode 100644 deploy/nginx-location.conf create mode 100644 go.mod create mode 100644 gpsd.go create mode 100644 main.go create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..407e2f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +LDFLAGS := -s -w -X main.version=$(VERSION) +HOST ?= yttrium.mfamily.org + +.PHONY: build arm64 run deploy clean + +build: ## native build + CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o build/gps-clock-dash . + +arm64: ## Raspberry Pi (64-bit) build + GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o build/gps-clock-dash-arm64 . + +run: build + ./build/gps-clock-dash + +deploy: arm64 ## copy binary + unit to $(HOST) and (re)start the service + scp build/gps-clock-dash-arm64 deploy/gps-clock-dash.service $(HOST):/tmp/ + ssh -t $(HOST) 'sudo install -m 0755 /tmp/gps-clock-dash-arm64 /usr/local/bin/gps-clock-dash && \ + sudo install -m 0644 /tmp/gps-clock-dash.service /etc/systemd/system/gps-clock-dash.service && \ + sudo systemctl daemon-reload && sudo systemctl enable --now gps-clock-dash && \ + sudo systemctl restart gps-clock-dash && systemctl --no-pager status gps-clock-dash | head -5' + +clean: + rm -rf build diff --git a/README.md b/README.md new file mode 100644 index 0000000..afbadef --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# gps-clock-dash + +A lightweight live dashboard for a GPS + PPS time server running chrony and gpsd. + +One static Go binary (about 5 MB, standard library only) runs on the time server. +Once a second it polls `chronyc -c tracking | sources | sourcestats`, keeps a +connection open to gpsd's JSON stream, and pushes a combined snapshot to +browsers over Server-Sent Events. The web page (HTML, CSS and vanilla JS, with +no build step and no external requests) is built into the binary. + +## Panels + +- **Clock**: UTC to the millisecond, local time, day of year, MJD, Unix time, + GPS week and time of week, leap status, and this browser's offset from the + server (measured with an NTP-style round trip to `api/time`). Also an overall + health badge: *Locked to PPS*, *Degraded* or *Not synchronised*, with reasons. +- **Time quality**: system offset, RMS offset, reference, stratum, frequency, + residual frequency, skew, root delay and dispersion, update interval. +- **Sources**: every chrony source with its selection state, poll interval, + 8-bit reach register, last sample offset ± error bound, and σ (standard deviation). +- **History**: system offset and PPS sample offset, frequency (ppm), and + satellites used/in view, over 10, 30 or 60 minutes (kept in memory by the server). +- **GPS receiver**: fix type, latitude and longitude (4 decimal places), + altitude, position error, DOPs, estimated NMEA time error, serial latency + (gpsd TOFF), and receiver/driver. +- **Satellites**: sky plot (azimuth/elevation, filled when used in the fix), + counts per constellation, and signal strength per satellite. + +## Build & deploy + +```sh +make arm64 # build/gps-clock-dash-arm64 for the Pi +make deploy # scp + install to /usr/local/bin + systemd unit on yttrium (needs sudo) +``` + +The service listens on **:8473**. + +| Flag | Default | | +|---|---|---| +| `-listen` | `:8473` | HTTP listen address | +| `-gpsd` | `127.0.0.1:2947` | gpsd address | +| `-chronyc` | `chronyc` | path to chronyc | +| `-pps-refid` | `GPS` | chrony refid of the PPS refclock (used for health and the history chart) | +| `-history` | `3600` | seconds of history to keep | + +### Endpoints + +`/` dashboard · `/events` SSE stream · `/api/status` latest snapshot · +`/api/history` history samples · `/api/time` server time · `/healthz` + +### Behind nginx + +All URLs in the page are relative, so it works at any sub-path. See +[deploy/nginx-location.conf](deploy/nginx-location.conf). The important parts +for SSE are `proxy_buffering off` and a long `proxy_read_timeout`. The server +also sends `X-Accel-Buffering: no`. Once nginx is in front, switch the unit to +`-listen 127.0.0.1:8473` (or the LAN address nginx connects to). + +### NTP client request rate (optional) + +`chronyc serverstats` needs chronyd's privileged socket, so the unit runs +unprivileged and that row stays hidden. To show it, run the service as +`_chrony` (see the comments in the unit file). The tradeoff is that the +process could then issue any chronyc command. diff --git a/chrony.go b/chrony.go new file mode 100644 index 0000000..2c50a78 --- /dev/null +++ b/chrony.go @@ -0,0 +1,175 @@ +package main + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// Tracking mirrors `chronyc -c tracking`. +type Tracking struct { + RefID string `json:"refId"` + RefName string `json:"refName"` + Stratum int `json:"stratum"` + RefTime float64 `json:"refTime"` + SystemOffset float64 `json:"systemOffset"` // seconds; positive = system clock slow of NTP time + LastOffset float64 `json:"lastOffset"` + RMSOffset float64 `json:"rmsOffset"` + FreqPPM float64 `json:"freqPpm"` // positive = clock runs slow + ResidFreqPPM float64 `json:"residFreqPpm"` + SkewPPM float64 `json:"skewPpm"` + RootDelay float64 `json:"rootDelay"` + RootDispersion float64 `json:"rootDispersion"` + UpdateInterval float64 `json:"updateInterval"` + Leap string `json:"leap"` +} + +// Source merges a `chronyc -c sources` row with its `sourcestats` row. +type Source struct { + Mode string `json:"mode"` // ^ server, = peer, # refclock + State string `json:"state"` // * + - x ~ ? + Name string `json:"name"` + Stratum int `json:"stratum"` + Poll int `json:"poll"` + Reach int `json:"reach"` + LastRx int64 `json:"lastRx"` // seconds ago, -1 if never + Offset float64 `json:"offset"` // adjusted offset of last sample + RawOffset float64 `json:"rawOffset"` + Error float64 `json:"error"` + + NP int `json:"np,omitempty"` + NR int `json:"nr,omitempty"` + Span int `json:"span,omitempty"` + Freq *float64 `json:"freqPpm,omitempty"` + Skew *float64 `json:"skewPpm,omitempty"` + StdDev *float64 `json:"stdDev,omitempty"` +} + +// ServerStats is the subset of `chronyc -c serverstats` we show. Requires +// access to chronyd's privileged command socket. +type ServerStats struct { + NTPReceived uint64 `json:"ntpReceived"` + NTPDropped uint64 `json:"ntpDropped"` + RatePerSec float64 `json:"ratePerSec"` +} + +type ChronySnap struct { + Tracking *Tracking `json:"tracking,omitempty"` + Sources []Source `json:"sources"` + Server *ServerStats `json:"server,omitempty"` + Error string `json:"error,omitempty"` +} + +type chronyClient struct { + path string + + serverStatsDenied bool + lastServerRx uint64 + lastServerAt time.Time +} + +func (c *chronyClient) run(ctx context.Context, cmd string) ([][]string, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, c.path, "-c", "-n", cmd).Output() + if err != nil { + msg := strings.TrimSpace(string(out)) + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + msg = strings.TrimSpace(string(ee.Stderr)) + } + if msg == "" { + msg = err.Error() + } + return nil, fmt.Errorf("chronyc %s: %s", cmd, msg) + } + var rows [][]string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line = strings.TrimSpace(line); line != "" { + rows = append(rows, strings.Split(line, ",")) + } + } + return rows, nil +} + +func f64(s string) float64 { v, _ := strconv.ParseFloat(s, 64); return v } +func atoi(s string) int { v, _ := strconv.Atoi(s); return v } + +func (c *chronyClient) Poll(ctx context.Context) ChronySnap { + snap := ChronySnap{Sources: []Source{}} + var errs []string + + if rows, err := c.run(ctx, "tracking"); err != nil { + errs = append(errs, err.Error()) + } else if len(rows) > 0 && len(rows[0]) >= 14 { + r := rows[0] + snap.Tracking = &Tracking{ + RefID: r[0], RefName: r[1], Stratum: atoi(r[2]), RefTime: f64(r[3]), + SystemOffset: f64(r[4]), LastOffset: f64(r[5]), RMSOffset: f64(r[6]), + FreqPPM: f64(r[7]), ResidFreqPPM: f64(r[8]), SkewPPM: f64(r[9]), + RootDelay: f64(r[10]), RootDispersion: f64(r[11]), + UpdateInterval: f64(r[12]), Leap: r[13], + } + } + + if rows, err := c.run(ctx, "sources"); err != nil { + errs = append(errs, err.Error()) + } else { + for _, r := range rows { + if len(r) < 10 { + continue + } + reach, _ := strconv.ParseInt(r[5], 8, 32) + lastRx, perr := strconv.ParseInt(r[6], 10, 64) + if perr != nil { + lastRx = -1 + } + snap.Sources = append(snap.Sources, Source{ + Mode: r[0], State: r[1], Name: r[2], Stratum: atoi(r[3]), Poll: atoi(r[4]), + Reach: int(reach), LastRx: lastRx, Offset: f64(r[7]), RawOffset: f64(r[8]), Error: f64(r[9]), + }) + } + } + + if rows, err := c.run(ctx, "sourcestats"); err != nil { + errs = append(errs, err.Error()) + } else { + byName := map[string]*Source{} + for i := range snap.Sources { + byName[snap.Sources[i].Name] = &snap.Sources[i] + } + for _, r := range rows { + if len(r) < 8 { + continue + } + if s := byName[r[0]]; s != nil { + fr, sk, sd := f64(r[4]), f64(r[5]), f64(r[7]) + s.NP, s.NR, s.Span = atoi(r[1]), atoi(r[2]), atoi(r[3]) + s.Freq, s.Skew, s.StdDev = &fr, &sk, &sd + } + } + } + + if !c.serverStatsDenied { + if rows, err := c.run(ctx, "serverstats"); err != nil { + if strings.Contains(err.Error(), "Not authorised") { + c.serverStatsDenied = true // don't keep spawning a doomed command + } + } else if len(rows) > 0 && len(rows[0]) >= 2 { + rx, _ := strconv.ParseUint(rows[0][0], 10, 64) + dr, _ := strconv.ParseUint(rows[0][1], 10, 64) + ss := &ServerStats{NTPReceived: rx, NTPDropped: dr} + now := time.Now() + if !c.lastServerAt.IsZero() && rx >= c.lastServerRx { + ss.RatePerSec = float64(rx-c.lastServerRx) / now.Sub(c.lastServerAt).Seconds() + } + c.lastServerRx, c.lastServerAt = rx, now + snap.Server = ss + } + } + + snap.Error = strings.Join(errs, "; ") + return snap +} diff --git a/deploy/gps-clock-dash.service b/deploy/gps-clock-dash.service new file mode 100644 index 0000000..0159595 --- /dev/null +++ b/deploy/gps-clock-dash.service @@ -0,0 +1,38 @@ +[Unit] +Description=GPS + PPS chrony time server dashboard +After=network.target chrony.service gpsd.socket +Wants=gpsd.socket + +[Service] +ExecStart=/usr/local/bin/gps-clock-dash -listen :8473 +# Once nginx fronts this, bind to loopback only: +# ExecStart=/usr/local/bin/gps-clock-dash -listen 127.0.0.1:8473 +Restart=on-failure +RestartSec=5 + +# Unprivileged, sandboxed. chronyc's monitoring commands (tracking, sources, +# sourcestats) work over chronyd's localhost UDP port without privileges. +DynamicUser=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native + +# Optional: to show the "NTP requests" (serverstats) row, chronyc needs +# chronyd's privileged socket in /run/chrony, which is owned by _chrony. +# Uncomment both lines below. Note this lets the process issue any chronyc +# command, including ones that change chronyd's behaviour. +#DynamicUser=no +#User=_chrony + +[Install] +WantedBy=multi-user.target diff --git a/deploy/nginx-location.conf b/deploy/nginx-location.conf new file mode 100644 index 0000000..b198d4e --- /dev/null +++ b/deploy/nginx-location.conf @@ -0,0 +1,15 @@ +# Example: serve the dashboard at https:///clock/ +# The page uses relative URLs, so any sub-path works. +location /clock/ { + proxy_pass http://yttrium.mfamily.org:8473/; # or 127.0.0.1:8473 if nginx runs on yttrium + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Server-Sent Events: no buffering, long-lived connection + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..528427c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gps-clock-dash + +go 1.24.4 diff --git a/gpsd.go b/gpsd.go new file mode 100644 index 0000000..371ad38 --- /dev/null +++ b/gpsd.go @@ -0,0 +1,230 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "log" + "net" + "sync" + "time" +) + +type gpsDevice struct { + Path string `json:"path"` + Driver string `json:"driver"` + Subtype string `json:"subtype"` + BPS int `json:"bps"` + Cycle float64 `json:"cycle"` +} + +type gpsTPV struct { + Mode int `json:"mode"` + Status int `json:"status"` + Time string `json:"time"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + AltMSL *float64 `json:"altMSL"` + AltHAE *float64 `json:"altHAE"` + GeoidSep *float64 `json:"geoidSep"` + Speed *float64 `json:"speed"` + EPT *float64 `json:"ept"` + EPH *float64 `json:"eph"` + EPV *float64 `json:"epv"` + EPX *float64 `json:"epx"` + EPY *float64 `json:"epy"` + Leap *int `json:"leapseconds"` +} + +type gpsSat struct { + PRN int `json:"PRN"` + GnssID *int `json:"gnssid"` + SvID *int `json:"svid"` + Az *float64 `json:"az"` + El *float64 `json:"el"` + SS *float64 `json:"ss"` + Used bool `json:"used"` +} + +type gpsDOP struct { + GDOP *float64 `json:"gdop"` + PDOP *float64 `json:"pdop"` + HDOP *float64 `json:"hdop"` + VDOP *float64 `json:"vdop"` + TDOP *float64 `json:"tdop"` +} + +type gpsTOFF struct { + // clock - real: how late the serial sentence arrived after the top of second. + Latency float64 `json:"latency"` +} + +type GPSSnap struct { + Connected bool `json:"connected"` + Error string `json:"error,omitempty"` + Version string `json:"version,omitempty"` + Device *gpsDevice `json:"device,omitempty"` + TPV *gpsTPV `json:"tpv,omitempty"` + TPVAge *float64 `json:"tpvAge,omitempty"` // seconds since last TPV + DOP gpsDOP `json:"dop"` + NSat *int `json:"nSat,omitempty"` + USat *int `json:"uSat,omitempty"` + Satellites []gpsSat `json:"satellites"` + TOFF *gpsTOFF `json:"toff,omitempty"` +} + +type gpsdClient struct { + addr string + + mu sync.Mutex + snap GPSSnap + lastTPV time.Time +} + +func newGPSD(addr string) *gpsdClient { + return &gpsdClient{addr: addr, snap: GPSSnap{Satellites: []gpsSat{}}} +} + +func (g *gpsdClient) Snapshot() GPSSnap { + g.mu.Lock() + defer g.mu.Unlock() + s := g.snap + s.Satellites = append([]gpsSat(nil), g.snap.Satellites...) + if !g.lastTPV.IsZero() { + age := time.Since(g.lastTPV).Seconds() + s.TPVAge = &age + } + return s +} + +func (g *gpsdClient) Run(ctx context.Context) { + backoff := time.Second + for ctx.Err() == nil { + err := g.session(ctx) + g.mu.Lock() + g.snap.Connected = false + if err != nil { + g.snap.Error = err.Error() + } + g.mu.Unlock() + if ctx.Err() != nil { + return + } + log.Printf("gpsd: %v (retrying in %s)", err, backoff) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } +} + +func (g *gpsdClient) session(ctx context.Context) error { + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", g.addr) + if err != nil { + return err + } + defer conn.Close() + go func() { <-ctx.Done(); conn.Close() }() + + if _, err := conn.Write([]byte(`?WATCH={"enable":true,"json":true,"pps":true};` + "\n")); err != nil { + return err + } + g.mu.Lock() + g.snap.Connected, g.snap.Error = true, "" + g.mu.Unlock() + + sc := bufio.NewScanner(conn) + sc.Buffer(make([]byte, 64*1024), 1024*1024) // SKY lines can be large + for { + conn.SetReadDeadline(time.Now().Add(15 * time.Second)) + if !sc.Scan() { + if err := sc.Err(); err != nil { + return err + } + return errEOF + } + g.handle(sc.Bytes()) + } +} + +type eofErr struct{} + +func (eofErr) Error() string { return "connection closed by gpsd" } + +var errEOF = eofErr{} + +func (g *gpsdClient) handle(line []byte) { + var head struct { + Class string `json:"class"` + } + if json.Unmarshal(line, &head) != nil { + return + } + g.mu.Lock() + defer g.mu.Unlock() + switch head.Class { + case "VERSION": + var v struct { + Release string `json:"release"` + } + if json.Unmarshal(line, &v) == nil { + g.snap.Version = v.Release + } + case "DEVICES": + var v struct { + Devices []gpsDevice `json:"devices"` + } + if json.Unmarshal(line, &v) == nil && len(v.Devices) > 0 { + g.snap.Device = &v.Devices[0] + } + case "DEVICE": + var v gpsDevice + if json.Unmarshal(line, &v) == nil { + if g.snap.Device == nil || g.snap.Device.Path == v.Path { + g.snap.Device = &v + } + } + case "TPV": + var v gpsTPV + if json.Unmarshal(line, &v) == nil { + g.snap.TPV = &v + g.lastTPV = time.Now() + } + case "SKY": + var v struct { + gpsDOP + NSat *int `json:"nSat"` + USat *int `json:"uSat"` + Satellites *[]gpsSat `json:"satellites"` + } + if json.Unmarshal(line, &v) != nil { + return + } + g.snap.DOP = v.gpsDOP + if v.USat != nil { + g.snap.USat = v.USat + } + // gpsd sends many partial SKY reports (DOPs only); keep the last full sat list. + if v.Satellites != nil { + g.snap.Satellites = *v.Satellites + n := len(*v.Satellites) + g.snap.NSat = &n + } + case "TOFF": + var v struct { + RealSec int64 `json:"real_sec"` + RealNsec int64 `json:"real_nsec"` + ClockSec int64 `json:"clock_sec"` + ClockNsec int64 `json:"clock_nsec"` + } + if json.Unmarshal(line, &v) == nil { + lat := float64(v.ClockSec-v.RealSec) + float64(v.ClockNsec-v.RealNsec)/1e9 + g.snap.TOFF = &gpsTOFF{Latency: lat} + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2b7be34 --- /dev/null +++ b/main.go @@ -0,0 +1,230 @@ +// gps-clock-dash: a lightweight live dashboard for a GPS + PPS chrony time server. +package main + +import ( + "context" + "embed" + "encoding/json" + "flag" + "fmt" + "io/fs" + "log" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +//go:embed web +var webFS embed.FS + +var version = "dev" + +// HistPoint is one 1 Hz sample kept for the history charts. +type HistPoint struct { + T float64 `json:"t"` + Sys *float64 `json:"sys"` // chrony system time offset + PPS *float64 `json:"pps"` // last sample offset of the PPS refclock + Freq *float64 `json:"freq"` // chrony frequency, ppm + Used *int `json:"used"` + Seen *int `json:"seen"` +} + +type Snapshot struct { + Now float64 `json:"now"` + Host string `json:"host"` + Version string `json:"version"` + PPSRefID string `json:"ppsRefId"` + Chrony ChronySnap `json:"chrony"` + GPS GPSSnap `json:"gps"` + Point HistPoint `json:"point"` +} + +type hub struct { + mu sync.Mutex + clients map[chan []byte]struct{} + latest []byte + history []HistPoint + maxHist int +} + +func (h *hub) publish(s *Snapshot) { + b, err := json.Marshal(s) + if err != nil { + log.Printf("marshal: %v", err) + return + } + h.mu.Lock() + defer h.mu.Unlock() + h.latest = b + h.history = append(h.history, s.Point) + if over := len(h.history) - h.maxHist; over > 0 { + h.history = append(h.history[:0:0], h.history[over:]...) + } + for c := range h.clients { + select { + case c <- b: + default: // slow client; it will get the next one + } + } +} + +func (h *hub) subscribe() (chan []byte, []byte) { + c := make(chan []byte, 2) + h.mu.Lock() + h.clients[c] = struct{}{} + latest := h.latest + h.mu.Unlock() + return c, latest +} + +func (h *hub) unsubscribe(c chan []byte) { + h.mu.Lock() + delete(h.clients, c) + h.mu.Unlock() +} + +func unixNow() float64 { return float64(time.Now().UnixNano()) / 1e9 } + +func main() { + listen := flag.String("listen", ":8473", "HTTP listen address") + gpsdAddr := flag.String("gpsd", "127.0.0.1:2947", "gpsd address") + chronyc := flag.String("chronyc", "chronyc", "path to chronyc") + ppsRef := flag.String("pps-refid", "GPS", "chrony refid of the PPS refclock (for the history chart)") + histLen := flag.Int("history", 3600, "number of 1-second history samples to keep") + showVer := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVer { + fmt.Println(version) + return + } + + host, _ := os.Hostname() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + gps := newGPSD(*gpsdAddr) + go gps.Run(ctx) + ch := &chronyClient{path: *chronyc} + h := &hub{clients: map[chan []byte]struct{}{}, maxHist: *histLen} + + // Sample once per second, shortly after the top of the second so the + // GPS fix for that second has arrived. + go func() { + for { + now := time.Now() + next := now.Truncate(time.Second).Add(time.Second + 150*time.Millisecond) + if next.Sub(now) > time.Second { + next = next.Add(-time.Second) + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Until(next)): + } + s := &Snapshot{Host: host, Version: version, PPSRefID: *ppsRef} + s.Chrony = ch.Poll(ctx) + s.GPS = gps.Snapshot() + s.Now = unixNow() + p := HistPoint{T: float64(time.Now().Unix())} + if t := s.Chrony.Tracking; t != nil { + p.Sys, p.Freq = &t.SystemOffset, &t.FreqPPM + } + for i := range s.Chrony.Sources { + if src := &s.Chrony.Sources[i]; src.Name == *ppsRef && src.Reach != 0 { + p.PPS = &src.Offset + } + } + if s.GPS.Connected { + p.Used, p.Seen = s.GPS.USat, s.GPS.NSat + } + s.Point = p + h.publish(s) + } + }() + + mux := http.NewServeMux() + static, _ := fs.Sub(webFS, "web") + mux.Handle("GET /", http.FileServer(http.FS(static))) + + mux.HandleFunc("GET /api/time", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"now":%.6f}`, unixNow()) + }) + + mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + b := h.latest + h.mu.Unlock() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + if b == nil { + http.Error(w, `{"error":"no data yet"}`, http.StatusServiceUnavailable) + return + } + w.Write(b) + }) + + mux.HandleFunc("GET /api/history", func(w http.ResponseWriter, r *http.Request) { + h.mu.Lock() + b, err := json.Marshal(h.history) + h.mu.Unlock() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + w.Write(b) + }) + + mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) { + fl, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("X-Accel-Buffering", "no") // tell nginx not to buffer + c, latest := h.subscribe() + defer h.unsubscribe(c) + fmt.Fprint(w, "retry: 3000\n\n") + if latest != nil { + fmt.Fprintf(w, "data: %s\n\n", latest) + } + fl.Flush() + for { + select { + case <-r.Context().Done(): + return + case b := <-c: + if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil { + return + } + fl.Flush() + } + } + }) + + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) }) + + srv := &http.Server{ + Addr: *listen, Handler: mux, ReadHeaderTimeout: 10 * time.Second, + BaseContext: func(net.Listener) context.Context { return ctx }, // ends SSE streams on shutdown + } + go func() { + <-ctx.Done() + sctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + srv.Shutdown(sctx) + }() + log.Printf("gps-clock-dash %s listening on %s (gpsd %s)", version, *listen, *gpsdAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..6b909a3 --- /dev/null +++ b/web/app.js @@ -0,0 +1,552 @@ +'use strict'; + +const $ = (id) => document.getElementById(id); +const HIST_MAX = 3600; + +/* ---------- constellation identity (fixed categorical order) ---------- */ +const CONSTS = [ + { id: 0, name: 'GPS', v: '--s1' }, + { id: 6, name: 'GLONASS', v: '--s2' }, + { id: 2, name: 'Galileo', v: '--s3' }, + { id: 3, name: 'BeiDou', v: '--s4' }, + { id: 5, name: 'QZSS', v: '--s5' }, + { id: 1, name: 'SBAS', v: '--s6' }, + { id: 7, name: 'NavIC', v: '--s7' }, + { id: 4, name: 'IMES', v: '--s8' }, +]; +const CONST_BY_ID = Object.fromEntries(CONSTS.map((c, i) => [c.id, { ...c, order: i }])); +function gnssOf(sat) { + if (sat.gnssid != null) return sat.gnssid; + const p = sat.PRN; // NMEA PRN fallbacks + if (p <= 32) return 0; + if (p <= 64 || (p >= 120 && p <= 158)) return 1; + if (p <= 96) return 6; + if (p >= 193 && p <= 202) return 5; + if (p >= 201 && p <= 237) return 3; + if (p >= 301 && p <= 336) return 2; + return 0; +} +const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + +/* ---------- formatting ---------- */ +function sig(v) { + const a = Math.abs(v); + if (a === 0) return '0'; + if (a >= 100) return a.toFixed(0); + if (a >= 10) return a.toFixed(1); + return a.toFixed(2); +} +function fmtSec(s, signed = true) { + if (s == null || !isFinite(s)) return '—'; + const a = Math.abs(s); + let v, u; + if (a < 1e-6) { v = s * 1e9; u = 'ns'; } + else if (a < 1e-3) { v = s * 1e6; u = 'µs'; } + else if (a < 1) { v = s * 1e3; u = 'ms'; } + else { v = s; u = 's'; } + const sign = signed ? (v < 0 ? '−' : '+') : ''; + return `${sign}${sig(v)} ${u}`; +} +function fmtDur(sec) { + if (sec == null || sec < 0) return '—'; + if (sec < 60) return `${Math.round(sec)} s`; + if (sec < 3600) return `${Math.round(sec / 60)} m`; + if (sec < 86400) return `${(sec / 3600).toFixed(1)} h`; + return `${(sec / 86400).toFixed(1)} d`; +} +const pad = (n, w = 2) => String(n).padStart(w, '0'); +const num = (v, d) => (v == null || !isFinite(v) ? '—' : v.toFixed(d)); +function fmtLat(v) { return v == null ? '—' : `${Math.abs(v).toFixed(4)}° ${v >= 0 ? 'N' : 'S'}`; } +function fmtLon(v) { return v == null ? '—' : `${Math.abs(v).toFixed(4)}° ${v >= 0 ? 'E' : 'W'}`; } +function utcHM(tSec) { const d = new Date(tSec * 1000); return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } +function utcHMS(tSec) { const d = new Date(tSec * 1000); return `${utcHM(tSec)}:${pad(d.getUTCSeconds())}`; } +function setText(id, text) { const el = $(id); if (el && el.textContent !== text) el.textContent = text; } + +/* ---------- browser ↔ server clock offset ---------- */ +let clockOff = null; // ms to add to Date.now() to get server time +let clockRtt = null; +async function syncClock() { + let best = null; + for (let i = 0; i < 6; i++) { + try { + const t0 = Date.now(); + const r = await fetch('api/time', { cache: 'no-store' }); + const j = await r.json(); + const t1 = Date.now(); + const s = { rtt: t1 - t0, off: j.now * 1000 - (t0 + t1) / 2 }; + if (!best || s.rtt < best.rtt) best = s; + } catch { /* ignore */ } + } + if (best) { clockOff = best.off; clockRtt = best.rtt; } +} + +/* ---------- state ---------- */ +let latest = null; +let history = []; +let range = 600; +let lastMsgAt = 0; +let gpsLeap = 18; // GPS−UTC; replaced by receiver value if reported + +/* ---------- clock banner (animation frame) ---------- */ +let lastWholeSec = -1; +function tickClock() { + const ms = Date.now() + (clockOff ?? 0); + const d = new Date(ms); + setText('utc-hms', `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`); + const whole = Math.floor(ms / 1000); + if (whole !== lastWholeSec) { + lastWholeSec = whole; + setText('utc-date', d.toLocaleDateString(undefined, { timeZone: 'UTC', weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })); + setText('local-time', d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short' }) + ', ' + + d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })); + const jan1 = Date.UTC(d.getUTCFullYear(), 0, 1); + setText('doy', pad(Math.floor((ms - jan1) / 86400000) + 1, 3)); + setText('mjd', (ms / 86400000 + 40587).toFixed(5)); + setText('unix', String(whole)); + const gps = whole - 315964800 + gpsLeap; + const wk = Math.floor(gps / 604800); + setText('gpswk', `${wk} / ${gps - wk * 604800}`); + if (clockOff != null) { + const o = -clockOff; // browser − server + const dir = Math.abs(o) < 0.5 ? 'in step' : o > 0 ? 'ahead' : 'behind'; + setText('browser-off', `${Math.abs(o).toFixed(0)} ms ${dir} (±${Math.ceil(clockRtt / 2)} ms)`); + } + // stale stream detection + if (lastMsgAt && Date.now() - lastMsgAt > 5000) setConn('dead', 'no data — reconnecting'); + } + requestAnimationFrame(tickClock); +} + +function setConn(cls, text) { + $('conn-dot').className = `dot ${cls}`; + setText('conn-text', text); +} + +/* ---------- health assessment ---------- */ +function assess(s) { + const reasons = []; + let level = 0; // 0 good, 1 warning, 2 critical + const bump = (l, r) => { level = Math.max(level, l); reasons.push(r); }; + const t = s.chrony.tracking; + const src = s.chrony.sources || []; + if (!t) bump(2, 'chronyd is not responding'); + else { + if (t.leap === 'Not synchronised' || t.stratum === 0 || t.stratum >= 16) bump(2, 'chronyd is not synchronised'); + const sel = src.find((x) => x.state === '*'); + if (!sel) bump(2, 'No source selected'); + else if (sel.name !== s.ppsRefId) bump(1, `Using ${sel.name} instead of PPS`); + const a = Math.abs(t.systemOffset); + if (a > 1e-3) bump(2, `System offset ${fmtSec(t.systemOffset, false)}`); + else if (a > 100e-6) bump(1, `System offset ${fmtSec(t.systemOffset, false)}`); + const pps = src.find((x) => x.name === s.ppsRefId); + if (pps && (pps.reach & 0x7) !== 0x7) bump(1, 'Recent PPS samples missed'); + } + const g = s.gps; + if (!g.connected) bump(1, 'gpsd unreachable'); + else if (!g.tpv || g.tpv.mode < 2) bump(1, 'No GPS fix'); + else if (g.tpvAge != null && g.tpvAge > 5) bump(1, `No GPS report for ${Math.round(g.tpvAge)} s`); + const text = level === 0 ? 'Locked to PPS' : level === 1 ? 'Degraded' : 'Not synchronised'; + return { cls: ['good', 'warning', 'critical'][level], text, reasons }; +} + +/* ---------- render: chrony ---------- */ +function renderChrony(s) { + const c = s.chrony; + const t = c.tracking; + const err = $('chrony-err'); + err.hidden = !c.error; err.textContent = c.error || ''; + if (t) { + setText('t-sys', fmtSec(Math.abs(t.systemOffset), false)); + setText('t-sys-dir', t.systemOffset >= 0 ? 'slow of NTP time' : 'fast of NTP time'); + setText('t-rms', fmtSec(t.rmsOffset, false)); + setText('t-ref', t.refName || t.refId); + setText('t-stratum', `stratum ${t.stratum} · refid ${t.refId}`); + setText('t-last', fmtSec(t.lastOffset)); + setText('t-freq', `${Math.abs(t.freqPpm).toFixed(3)} ppm ${t.freqPpm >= 0 ? 'slow' : 'fast'}`); + setText('t-resid', `${t.residFreqPpm >= 0 ? '+' : '−'}${Math.abs(t.residFreqPpm).toFixed(3)} ppm`); + setText('t-skew', `${t.skewPpm.toFixed(3)} ppm`); + setText('t-rdelay', fmtSec(t.rootDelay, false)); + setText('t-rdisp', fmtSec(t.rootDispersion, false)); + setText('t-upd', `${t.updateInterval.toFixed(1)} s`); + setText('t-reftime', t.refTime ? `${utcHMS(t.refTime)} UTC (${fmtDur(s.now - t.refTime)} ago)` : '—'); + setText('leap', t.leap); + } + document.querySelectorAll('.srv').forEach((el) => { el.hidden = !c.server; }); + if (c.server) setText('t-srv', `${c.server.ratePerSec.toFixed(1)}/s · ${c.server.ntpReceived.toLocaleString()} total`); + + const rows = (c.sources || []).map((x) => { + const type = x.mode === '#' ? 'refclock' : x.mode === '=' ? 'peer' : 'server'; + let dots = ''; + for (let b = 7; b >= 0; b--) dots += ``; + const stateTxt = x.state === '-' ? '−' : x.state; + return ` + ${stateTxt} + ${esc(x.name)} + ${x.mode === '#' ? type : `${type === 'peer' ? 'peer' : 'NTP'} s${x.stratum}`} + ${fmtDur(2 ** x.poll)} + ${dots} + ${x.lastRx < 0 ? '—' : fmtDur(x.lastRx)} + ${fmtSec(x.offset)}
±${fmtSec(x.error, false)}${x.stdDev != null ? ` · σ ${fmtSec(x.stdDev, false)}` : ''}
+ `; + }); + $('sources').innerHTML = rows.join('') || 'No sources'; +} +function esc(s) { return String(s).replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch])); } + +/* ---------- render: GPS ---------- */ +const FIX = ['Unknown', 'No fix', '2D', '3D']; +const GSTATUS = ['', 'GPS', 'DGPS', 'RTK fixed', 'RTK float', 'Dead reckoning', 'GNSS + DR', 'Surveyed time', 'Simulated', 'P(Y)']; +function renderGPS(s) { + const g = s.gps; + const err = $('gps-err'); + err.hidden = g.connected; err.textContent = g.connected ? '' : `gpsd: ${g.error || 'not connected'}`; + const p = g.tpv || {}; + setText('g-fix', g.tpv ? `${FIX[p.mode] ?? '—'}${p.mode >= 2 ? ' fix' : ''}` : '—'); + setText('g-status', GSTATUS[p.status] || (g.tpv ? 'GPS' : ' ')); + setText('g-used', g.uSat != null ? String(g.uSat) : '—'); + setText('g-seen', g.nSat != null ? String(g.nSat) : '—'); + setText('g-ept', p.ept != null ? `±${fmtSec(p.ept, false)}` : '—'); + setText('g-lat', fmtLat(p.lat)); + setText('g-lon', fmtLon(p.lon)); + setText('g-alt', p.altMSL != null ? `${p.altMSL.toFixed(1)} m${p.altHAE != null ? ` (HAE ${p.altHAE.toFixed(1)} m)` : ''}` : '—'); + setText('g-eph', p.eph != null ? `±${p.eph.toFixed(1)} m horiz${p.epv != null ? ` · ±${p.epv.toFixed(1)} m vert` : ''}` : '—'); + const d = g.dop || {}; + setText('g-dop', `${num(d.pdop, 2)} / ${num(d.hdop, 2)} / ${num(d.vdop, 2)} / ${num(d.tdop, 2)}`); + setText('g-toff', g.toff ? fmtSec(g.toff.latency, false) : '—'); + setText('g-time', p.time ? `${p.time.replace('T', ' ').replace('Z', ' UTC')}${g.tpvAge != null && g.tpvAge > 3 ? ` (${Math.round(g.tpvAge)} s old)` : ''}` : '—'); + const dev = g.device; + setText('g-dev', dev ? `${dev.driver || 'unknown'}${dev.subtype ? ' ' + dev.subtype.split(',')[0] : ''} on ${dev.path}${dev.bps ? ` @ ${dev.bps}` : ''}${g.version ? ` · gpsd ${g.version}` : ''}` : '—'); + if (p.leapseconds) gpsLeap = p.leapseconds; + renderSky(g.satellites || []); +} + +/* ---------- sky plot ---------- */ +function skyBase() { + let h = ''; + for (const el of [0, 30, 60]) h += ``; + for (let az = 0; az < 360; az += 45) { + const r = (az * Math.PI) / 180; + h += ``; + } + h += `NESW`; + h += `30°60°`; + return h + ''; +} +function sortSats(sats) { + return [...sats].sort((a, b) => { + const ca = CONST_BY_ID[gnssOf(a)]?.order ?? 99, cb = CONST_BY_ID[gnssOf(b)]?.order ?? 99; + return ca - cb || (a.svid ?? a.PRN) - (b.svid ?? b.PRN); + }); +} +function satLabel(x) { + const c = CONST_BY_ID[gnssOf(x)]; + return `${c ? c.name : 'Sat'} ${x.svid ?? x.PRN}`; +} +function renderSky(sats) { + const sorted = sortSats(sats); + // sky + let h = ''; + // draw unused first so used sit on top + for (const x of [...sorted].sort((a, b) => a.used - b.used)) { + if (x.az == null || x.el == null || x.el < 0) continue; + const c = CONST_BY_ID[gnssOf(x)] || CONSTS[0]; + const r = (90 * (90 - x.el)) / 90, a = (x.az * Math.PI) / 180; + const cx = (r * Math.sin(a)).toFixed(1), cy = (-r * Math.cos(a)).toFixed(1); + const col = `var(${c.v})`; + const style = x.used ? `fill:${col}` : `stroke:${col}`; + h += ``; + h += `${x.svid ?? x.PRN}`; + } + const g = $('sky-sats'); + if (g) g.innerHTML = h; + + // constellation table + const counts = new Map(); + for (const x of sorted) { + const id = gnssOf(x); + const e = counts.get(id) || { used: 0, seen: 0 }; + e.seen++; if (x.used) e.used++; + counts.set(id, e); + } + $('const').innerHTML = [...counts.entries()] + .sort((a, b) => (CONST_BY_ID[a[0]]?.order ?? 99) - (CONST_BY_ID[b[0]]?.order ?? 99)) + .map(([id, e]) => { + const c = CONST_BY_ID[id] || { name: `gnss ${id}`, v: '--muted' }; + return `${c.name}${e.used}${e.seen}`; + }).join('') || 'No satellites'; + + // SNR bars + const MAX = 50; + $('snr').innerHTML = sorted.map((x) => { + const c = CONST_BY_ID[gnssOf(x)] || CONSTS[0]; + const ss = x.ss ?? 0; + const hPct = Math.max(0, Math.min(1, ss / MAX)) * 100; + return `
+ ${ss ? Math.round(ss) : ''} +
+ ${x.svid ?? x.PRN}
`; + }).join(''); +} +function satTip(x) { + return `${satLabel(x)}${x.svid != null && x.svid !== x.PRN ? ` (PRN ${x.PRN})` : ''}|Signal|${x.ss != null ? x.ss.toFixed(0) + ' dB-Hz' : '—'}|Elevation|${x.el != null ? x.el.toFixed(0) + '°' : '—'}|Azimuth|${x.az != null ? x.az.toFixed(0) + '°' : '—'}|Status|${x.used ? 'used in fix' : 'tracked only'}`; +} + +/* ---------- tooltip ---------- */ +const tip = $('tip'); +function showTip(html, clientX, clientY) { + tip.innerHTML = html; + tip.hidden = false; + const w = tip.offsetWidth, h = tip.offsetHeight; + let x = clientX + 14, y = clientY + 14; + if (x + w > window.innerWidth - 8) x = clientX - w - 14; + if (y + h > window.innerHeight - 8) y = clientY - h - 14; + tip.style.left = `${Math.max(8, x)}px`; + tip.style.top = `${Math.max(8, y)}px`; +} +function hideTip() { tip.hidden = true; } +document.addEventListener('pointerover', (e) => { + const el = e.target.closest?.('[data-tip]'); + if (!el) return; + const parts = el.dataset.tip.split('|'); + let h = `
${esc(parts[0])}
`; + for (let i = 1; i + 1 < parts.length; i += 2) h += `
${esc(parts[i])}${esc(parts[i + 1])}
`; + showTip(h, e.clientX, e.clientY); +}); +document.addEventListener('pointermove', (e) => { + if (!tip.hidden && e.target.closest?.('[data-tip]')) showTip(tip.innerHTML, e.clientX, e.clientY); +}); +document.addEventListener('pointerout', (e) => { if (e.target.closest?.('[data-tip]')) hideTip(); }); + +/* ---------- line charts (canvas) ---------- */ +class LineChart { + constructor(fig, series, fmtY, fmtTip) { + this.fig = fig; this.series = series; this.fmtY = fmtY; this.fmtTip = fmtTip || fmtY; + this.canvas = document.createElement('canvas'); + fig.appendChild(this.canvas); + if (series.length > 1) { + fig.querySelector('figcaption').innerHTML += series.map((s) => ` ${s.label}`).join(''); + } + this.hoverX = null; + this.canvas.addEventListener('pointermove', (e) => { this.hoverX = e.offsetX; this.draw(); this.tip(e); }); + this.canvas.addEventListener('pointerleave', () => { this.hoverX = null; hideTip(); this.draw(); }); + } + draw() { + const cv = this.canvas, dpr = window.devicePixelRatio || 1; + const W = cv.clientWidth, H = cv.clientHeight; + if (!W || !H) return; + if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) { cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr); } + const ctx = cv.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, W, H); + + const tMax = history.length ? history[history.length - 1].t : Date.now() / 1000; + const tMin = tMax - range; + const pts = history.filter((p) => p.t >= tMin); + let lo = Infinity, hi = -Infinity; + for (const p of pts) for (const s of this.series) { const v = p[s.key]; if (v != null) { lo = Math.min(lo, v); hi = Math.max(hi, v); } } + if (!isFinite(lo)) { lo = 0; hi = 1; } + if (this.series[0].zero) { lo = Math.min(lo, 0); hi = Math.max(hi, 0); } + if (hi - lo < 1e-12) { const d = Math.abs(hi) * 0.1 || 1e-9; lo -= d; hi += d; } + const ticks = niceTicks(lo, hi, 4); + lo = Math.min(lo, ticks[0]); hi = Math.max(hi, ticks[ticks.length - 1]); + + ctx.font = '11px system-ui, -apple-system, "Segoe UI", sans-serif'; + const labels = ticks.map((v) => this.fmtY(v)); + const padL = Math.ceil(Math.max(...labels.map((l) => ctx.measureText(l).width))) + 8; + const padR = 6, padT = 6, padB = 18; + const pw = W - padL - padR, ph = H - padT - padB; + const x = (t) => padL + ((t - tMin) / range) * pw; + const y = (v) => padT + (1 - (v - lo) / (hi - lo)) * ph; + this.geom = { x, y, tMin, padL, pw, pts }; + + // grid + y labels + ctx.fillStyle = css('--muted'); ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + ticks.forEach((v, i) => { + const yy = Math.round(y(v)) + 0.5; + ctx.strokeStyle = v === 0 ? css('--axis') : css('--grid'); ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(W - padR, yy); ctx.stroke(); + ctx.fillText(labels[i], padL - 6, yy); + }); + // x labels + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + const step = range <= 600 ? 120 : range <= 1800 ? 600 : 900; + for (let t = Math.ceil(tMin / step) * step; t <= tMax; t += step) { + const xx = x(t); + if (xx < padL + 14 || xx > W - padR - 14) continue; + ctx.fillText(utcHM(t), xx, H - padB + 4); + } + // lines + for (const s of this.series) { + ctx.strokeStyle = css(s.v); ctx.lineWidth = 2; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; + ctx.beginPath(); + let pen = false, prevT = null; + for (const p of pts) { + const v = p[s.key]; + if (v == null || (prevT != null && p.t - prevT > 5)) pen = false; + if (v != null) { if (pen) ctx.lineTo(x(p.t), y(v)); else ctx.moveTo(x(p.t), y(v)); pen = true; } + prevT = p.t; + } + ctx.stroke(); + } + // crosshair + const hp = this.hoverPoint(); + if (hp) { + const xx = Math.round(x(hp.t)) + 0.5; + ctx.strokeStyle = css('--axis'); ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(xx, padT); ctx.lineTo(xx, padT + ph); ctx.stroke(); + for (const s of this.series) { + const v = hp[s.key]; if (v == null) continue; + ctx.fillStyle = css(s.v); ctx.strokeStyle = css('--surface'); ctx.lineWidth = 2; + ctx.beginPath(); ctx.arc(x(hp.t), y(v), 4, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + } + } + hoverPoint() { + if (this.hoverX == null || !this.geom || !this.geom.pts.length) return null; + const { tMin, padL, pw, pts } = this.geom; + const t = tMin + ((this.hoverX - padL) / pw) * range; + let best = null, bd = Infinity; + for (const p of pts) { const d = Math.abs(p.t - t); if (d < bd) { bd = d; best = p; } } + return bd <= range / 50 + 2 ? best : null; + } + tip(e) { + const hp = this.hoverPoint(); + if (!hp) { hideTip(); return; } + let h = `
${utcHMS(hp.t)} UTC
`; + for (const s of this.series) h += `
${s.label}${hp[s.key] == null ? '—' : this.fmtTip(hp[s.key])}
`; + showTip(h, e.clientX, e.clientY); + } +} +function niceTicks(lo, hi, n) { + const span = hi - lo; + const raw = span / n; + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => span / s <= n) || 10 * mag; + const out = []; + for (let v = Math.floor(lo / step) * step; v <= hi + step * 0.5; v += step) out.push(Math.abs(v) < step * 1e-6 ? 0 : v); + return out; +} + +const charts = [ + new LineChart($('ch-offset'), [ + { key: 'sys', label: 'System (+ = slow)', v: '--s1', zero: true }, + { key: 'pps', label: 'PPS sample', v: '--s2' }, + ], (v) => fmtSec(v).replace('+', '')), + new LineChart($('ch-freq'), [{ key: 'freq', label: 'Frequency', v: '--s1' }], (v) => v.toFixed(3)), + new LineChart($('ch-sats'), [ + { key: 'seen', label: 'In view', v: '--s1' }, + { key: 'used', label: 'Used', v: '--s2' }, + ], (v) => String(Math.round(v * 10) / 10), (v) => String(v)), +]; +function drawCharts() { if (!document.hidden) charts.forEach((c) => c.draw()); } +window.addEventListener('resize', drawCharts); +document.querySelectorAll('.range button').forEach((b) => b.addEventListener('click', () => { + range = +b.dataset.range; + document.querySelectorAll('.range button').forEach((o) => o.classList.toggle('on', o === b)); + drawCharts(); +})); + +/* ---------- main update ---------- */ +function onSnapshot(s) { + latest = s; + lastMsgAt = Date.now(); + setConn('live', 'live'); + if (clockOff == null) clockOff = s.now * 1000 - Date.now(); + setText('host', s.host); + setText('ver', s.version); + document.title = `${s.host} · GPS Clock`; + + const a = assess(s); + $('status').className = `status ${a.cls}`; + setText('status-text', a.text); + $('status-reasons').innerHTML = a.reasons.map((r) => `
  • ${esc(r)}
  • `).join(''); + + renderChrony(s); + renderGPS(s); + + if (s.point && (!history.length || s.point.t > history[history.length - 1].t)) { + history.push(s.point); + if (history.length > HIST_MAX) history.splice(0, history.length - HIST_MAX); + } + drawCharts(); +} + +function connect() { + const es = new EventSource('events'); + es.onmessage = (e) => { try { onSnapshot(JSON.parse(e.data)); } catch (err) { console.error(err); } }; + es.onerror = () => setConn('dead', 'disconnected — retrying'); +} + +/* ---------- theme toggle ---------- */ +(function theme() { + const root = document.documentElement; + try { const t = localStorage.getItem('theme'); if (t) root.dataset.theme = t; } catch { /* ignore */ } + $('theme').addEventListener('click', () => { + const dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches; + root.dataset.theme = dark ? 'light' : 'dark'; + try { localStorage.setItem('theme', root.dataset.theme); } catch { /* ignore */ } + drawCharts(); + }); + matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', drawCharts); +})(); + +/* ---------- hover help ---------- */ +const HELP = { + 'utc-hms': 'Current UTC from this server, kept in step with the server clock (not your browser clock).', + 'local-time': 'The same instant in this browser\'s local time zone.', + doy: 'Day of the year in UTC (001–366). Common in logs and scientific timestamps.', + mjd: 'Modified Julian Date: days since 1858-11-17 00:00 UTC, with a fractional day. Used in astronomy and timing labs.', + unix: 'Seconds since 1970-01-01 00:00 UTC, not counting leap seconds. This is what most computers store.', + gpswk: 'GPS week number and seconds into that week (Time of Week). GPS time ignores leap seconds, so it is currently ahead of UTC by the GPS−UTC leap offset.', + leap: 'chronyd\'s leap-second state. "Normal" is expected. "Insert/Delete second" means a leap second is scheduled for the end of the day. "Not synchronised" means chronyd has no usable reference.', + 'browser-off': 'How far this browser\'s clock differs from the server, measured over HTTP. ± is the uncertainty from network round-trip time. This affects only this page, not the server.', + status: 'Overall health. "Locked to PPS" means chronyd is steering to the PPS reference with small offset and the GPS has a fix. "Degraded" means it still works but something needs attention (reasons listed below). "Not synchronised" means the clock is not being disciplined.', + 't-sys': 'How far chronyd estimates the system clock is from true time right now. It is slewing the clock to remove this. For a PPS-disciplined server this should be well under 10 µs.', + 't-rms': 'Long-term average of measured offsets (root mean square). Shows how tightly the clock is held over time, independent of the current moment.', + 't-ref': 'The source chronyd is currently synchronised to and its stratum. Stratum 1 means directly attached to a reference clock such as GPS/PPS. Refid is the short identifier other NTP clients see.', + 't-last': 'Offset measured on the most recent clock update. Shows how much the clock needed correcting at that moment.', + 't-freq': 'How fast or slow the system\'s crystal oscillator would run without correction, in parts per million. 1 ppm ≈ 86 ms per day. It changes slowly with temperature.', + 't-resid': 'Difference between the frequency the reference suggests and the one in use. Near zero means chronyd\'s frequency estimate is accurate.', + 't-skew': 'Estimated error bound on the frequency. Smaller means chronyd is more confident about the oscillator\'s rate.', + 't-rdelay': 'Total network round-trip delay back to the stratum-1 reference. For a local PPS refclock this is essentially zero.', + 't-rdisp': 'Total accumulated uncertainty back to the reference. Together with root delay it bounds how wrong the clock could be (max error ≈ dispersion + delay/2). Clients use it to pick the best server.', + 't-upd': 'Seconds between the last two clock corrections. Tracks the reference\'s polling interval (about 16 s for a typical PPS refclock). A much longer value suggests missed samples or a fallback to slower NTP sources.', + 't-reftime': 'When chronyd last took a measurement from the reference. This should always be recent (within about one update interval). If it falls behind, chronyd is running on its own oscillator without fresh data.', + 't-srv': 'NTP requests this server is answering for clients: current rate and total since chronyd started.', + 'g-fix': 'GPS position fix. A 3D fix (4+ satellites) is best. Timing works with a 2D fix, and a surveyed-in timing receiver may keep good time with fewer. "No fix" means PPS output may be unreliable or absent.', + 'g-used': 'Satellites used in the fix, out of those the receiver is tracking. More used satellites give better timing stability; at least 4 is needed for a 3D fix.', + 'g-ept': 'The receiver\'s estimate of the error in its reported time. This applies to the NMEA serial time, which is only coarse. PPS supplies the precise second edge.', + 'g-lat': 'Antenna latitude reported by the receiver.', + 'g-lon': 'Antenna longitude reported by the receiver.', + 'g-alt': 'Antenna altitude above mean sea level (MSL). HAE is height above the WGS-84 ellipsoid, which GPS uses internally.', + 'g-eph': 'Estimated 95% position uncertainty, horizontal and vertical. Large values mean a weak or poorly spread set of satellites.', + 'g-dop': 'Dilution of precision: how satellite geometry amplifies errors in Position, Horizontal, Vertical and Time. Lower is better; under 2 is excellent and over 5 is poor. TDOP matters most for timing.', + 'g-toff': 'Delay from the top of the second to when the NMEA time message arrives over serial (gpsd TOFF). Useful for tuning the offset of an NMEA refclock; it does not affect PPS.', + 'g-time': 'UTC time in the receiver\'s latest fix report. Flags how old it is if reports stop arriving.', + 'g-dev': 'gpsd driver, device path and serial speed of the receiver, and the gpsd version.', +}; +function applyHelp() { + for (const [id, text] of Object.entries(HELP)) { + const el = $(id); + if (!el) continue; + const tile = el.closest('.tile'); + if (tile && id !== 'status') { tile.title = text; continue; } + el.title = text; + if (el.tagName === 'DD' && el.previousElementSibling?.tagName === 'DT') el.previousElementSibling.title = text; + } +} + +/* ---------- boot ---------- */ +applyHelp(); +$('sky').innerHTML = skyBase(); +requestAnimationFrame(tickClock); +Promise.all([ + fetch('api/history', { cache: 'no-store' }).then((r) => r.json()).catch(() => null), + fetch('api/status', { cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null), +]).then(([h, s]) => { + if (Array.isArray(h)) history = h; + if (s && !latest) onSnapshot(s); else drawCharts(); +}).finally(connect); +syncClock(); +setInterval(syncClock, 5 * 60 * 1000); +document.addEventListener('visibilitychange', drawCharts); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f4d8ec8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,134 @@ + + + + + +GPS Clock + + + + +
    +
    — GPS + PPS time server
    +
    connecting… +
    +
    + +
    + +
    +
    +
    UTC
    +
    --:--:--
    +
    —
    +
    +
    +
    +
    Local
    —
    +
    Day of year
    —
    +
    MJD
    —
    +
    Unix
    —
    +
    GPS week / TOW
    —
    +
    Leap status
    —
    +
    Browser clock
    —
    +
    +
    +
    + +
    + +
    +

    Time quality chronyc tracking

    +
    +
    System offset
    —
     
    +
    RMS offset
    —
    long-term avg
    +
    Reference
    —
     
    +
    Waiting for data
    +
    +
      +
      +
      Last offset
      —
      +
      Frequency
      —
      +
      Residual freq
      —
      +
      Skew
      —
      +
      Root delay
      —
      +
      Root dispersion
      —
      +
      Update interval
      —
      +
      Ref time
      —
      +
      NTP requests
      —
      +
      + +
      + + +
      +

      Sources chronyc sources / sourcestats

      +
      + + + +
      SNameTypePollReachLastOffset
      +
      +

      * selected · + combined · − not combined · x falseticker · ~ too variable · ? unusable

      +
      +
      + + +
      +

      History + + + +

      +
      +
      Offset
      +
      Frequency (ppm)
      +
      Satellites
      +
      +
      + +
      + +
      +

      GPS receiver gpsd

      +
      +
      Fix
      —
       
      +
      Satellites
      — / —
      used / in view
      +
      Time error (est.)
      —
      NMEA, before PPS
      +
      +
      +
      Latitude
      —
      +
      Longitude
      —
      +
      Altitude (MSL)
      —
      +
      Position error
      —
      +
      DOP (P / H / V / T)
      —
      +
      Serial latency
      —
      +
      GPS fix time
      —
      +
      Receiver
      —
      +
      + +
      + + +
      +

      Satellites sky view & signal

      +
      + +
      + + + +
      ConstellationUsedSeen
      +

      used in fix   tracked only

      +
      +
      +
      Signal strength (dB-Hz)
      + +
      +
      +
      + +
      gps-clock-dash · updates every second
      + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..15ced4b --- /dev/null +++ b/web/style.css @@ -0,0 +1,214 @@ +:root { + color-scheme: light; + --page: #f9f9f7; + --surface: #fcfcfb; + --text: #0b0b0b; + --text-2: #52514e; + --muted: #898781; + --grid: #e1e0d9; + --axis: #c3c2b7; + --border: rgba(11, 11, 11, 0.10); + --wash: rgba(11, 11, 11, 0.04); + + /* categorical (fixed order) */ + --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; + --s5: #e87ba4; --s6: #008300; --s7: #6250d6; --s8: #e34948; + + /* status (reserved) */ + --good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b; + --good-text: #006300; + + --mono: ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781; + --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05); + --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; + --s5: #d55181; --s6: #008300; --s7: #9085e9; --s8: #e66767; + --good-text: #0ca30c; + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --page: #0d0d0d; --surface: #1a1a19; --text: #f0efec; --text-2: #c3c2b7; --muted: #898781; + --grid: #2c2c2a; --axis: #383835; --border: rgba(255,255,255,0.10); --wash: rgba(255,255,255,0.05); + --s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; + --s5: #d55181; --s6: #008300; --s7: #9085e9; --s8: #e66767; + --good-text: #0ca30c; +} + +* { box-sizing: border-box; } +html, body { margin: 0; } +body { + background: var(--page); + color: var(--text); + font: 14px/1.45 var(--sans); + -webkit-font-smoothing: antialiased; +} +.muted { color: var(--muted); } +main { max-width: 1280px; margin: 0 auto; padding: 0 16px 16px; display: grid; gap: 16px; } + +.top { + max-width: 1280px; margin: 0 auto; padding: 14px 16px; + display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; +} +.brand { font-weight: 600; font-size: 15px; } +.brand .muted { font-weight: 400; margin-left: 6px; } +.conn { display: flex; align-items: center; gap: 8px; color: var(--text-2); font-size: 13px; } +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); display: inline-block; } +.dot.live { background: var(--good); } +.dot.dead { background: var(--critical); } +#theme { + margin-left: 8px; background: none; border: 1px solid var(--border); color: var(--text-2); + border-radius: 6px; width: 28px; height: 28px; cursor: pointer; font-size: 14px; +} +#theme:hover { background: var(--wash); } + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 18px; + min-width: 0; +} +h2 { + margin: 0 0 12px; font-size: 13px; font-weight: 600; letter-spacing: .02em; + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; +} +h2 .sub { font-weight: 400; color: var(--muted); font-size: 12px; } + +.grid { display: grid; gap: 16px; } +.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +@media (max-width: 900px) { + .grid.two, .grid.three { grid-template-columns: minmax(0, 1fr); } +} + +/* Clock */ +.clock { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 24px; align-items: center; } +@media (max-width: 900px) { .clock { grid-template-columns: minmax(0, 1fr); } } +.clock .label { color: var(--muted); font-size: 12px; letter-spacing: .12em; font-weight: 600; } +.utc { + font-family: var(--mono); font-variant-numeric: tabular-nums; + font-size: clamp(44px, 9vw, 104px); line-height: 1.05; font-weight: 600; letter-spacing: -0.02em; + white-space: nowrap; +} +.date { font-size: clamp(16px, 2.2vw, 22px); color: var(--text-2); margin-top: 4px; } + +.status { + display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; + border-radius: 999px; font-weight: 600; font-size: 14px; + border: 1px solid var(--border); background: var(--wash); +} +.status .status-icon { font-size: 12px; } +.status.good .status-icon { color: var(--good); } +.status.warning .status-icon { color: var(--warning); } +.status.critical .status-icon { color: var(--critical); } +.status.unknown .status-icon { color: var(--muted); } +.tile .status { margin-top: 6px; padding: 3px 9px; font-size: 12px; gap: 6px; max-width: 100%; white-space: nowrap; overflow: hidden; } +.tile .status .status-icon { font-size: 10px; } +.tile .status #status-text { overflow: hidden; text-overflow: ellipsis; } +.tiles + .reasons { margin: -6px 0 12px; } +.reasons { margin: 6px 0 10px; padding-left: 18px; color: var(--text-2); font-size: 13px; } +.reasons:empty { display: none; } + +/* Hover help */ +[title].tile, .kv dt[title], th[title], figcaption[title], .snr-head [title] { cursor: help; } +.kv dt[title], .tile[title] .tile-label, th[title], figcaption[title], .snr-head [title] { + text-decoration: underline dotted var(--axis); text-underline-offset: 3px; +} + +/* Key/value lists */ +.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 0; } +.kv dt { color: var(--text-2); } +.kv dd { margin: 0; font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 13px; overflow-wrap: anywhere; } +.kv.compact { gap: 2px 14px; font-size: 13px; } +.kv .srv[hidden] { display: none; } + +/* Stat tiles */ +.tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-bottom: 14px; } +.tile { background: var(--wash); border-radius: 8px; padding: 10px 12px; min-width: 0; } +.tile-label { color: var(--text-2); font-size: 12px; } +.tile-value { font-size: 24px; font-weight: 600; line-height: 1.2; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tile-value .of { color: var(--muted); font-weight: 400; font-size: 18px; } +.tile-sub { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +@media (max-width: 480px) { .tile-value { font-size: 19px; } } + +.err { color: var(--critical); font-size: 13px; margin: 10px 0 0; } + +/* Sources table */ +.table-wrap { overflow-x: auto; } +table { border-collapse: collapse; width: 100%; font-size: 13px; } +th { text-align: left; color: var(--muted); font-weight: 500; font-size: 12px; padding: 4px 6px 6px; border-bottom: 1px solid var(--axis); white-space: nowrap; } +td { padding: 6px 6px; border-bottom: 1px solid var(--grid); white-space: nowrap; font-variant-numeric: tabular-nums; } +td.num, th.num { text-align: right; } +td.mono { font-family: var(--mono); } +tr.selected td { background: var(--wash); } +tr.selected td:first-child { box-shadow: inset 3px 0 0 var(--good); } +.state { font-family: var(--mono); font-weight: 700; } +.name-cell { max-width: 150px; overflow: hidden; text-overflow: ellipsis; } +.reach { display: inline-flex; gap: 2px; vertical-align: middle; } +.reach i { width: 6px; height: 10px; border-radius: 2px; background: var(--grid); display: inline-block; } +.reach i.on { background: var(--text-2); } +.legend-note { color: var(--muted); font-size: 12px; margin: 10px 0 0; } + +/* Range toggle */ +.range { margin-left: auto; display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.range button { + background: none; border: 0; color: var(--text-2); font: inherit; font-size: 12px; font-weight: 500; + padding: 4px 10px; cursor: pointer; +} +.range button + button { border-left: 1px solid var(--border); } +.range button.on { background: var(--wash); color: var(--text); font-weight: 600; } + +/* Charts */ +.chart { margin: 0; position: relative; min-width: 0; } +.chart figcaption { font-size: 12px; color: var(--text-2); margin-bottom: 4px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; } +.chart figcaption .lg { display: inline-flex; align-items: center; gap: 5px; color: var(--text-2); } +.chart figcaption .lg i { width: 12px; height: 2px; border-radius: 1px; display: inline-block; } +.chart canvas { width: 100%; height: 160px; display: block; touch-action: pan-y; } + +.tip { + position: fixed; z-index: 10; pointer-events: none; + background: var(--surface); color: var(--text); border: 1px solid var(--border); + border-radius: 8px; padding: 6px 10px; font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.18); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.tip .t { color: var(--muted); margin-bottom: 2px; } +.tip .row { display: flex; align-items: center; gap: 6px; } +.tip .row i { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.tip .row b { margin-left: auto; padding-left: 12px; font-weight: 600; } + +/* Sky plot */ +.sky-wrap { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr); gap: 16px; align-items: start; } +@media (max-width: 520px) { .sky-wrap { grid-template-columns: minmax(0, 1fr); } #sky { max-width: 300px; margin: 0 auto; } } +#sky { width: 100%; height: auto; display: block; } +#sky .ring { fill: none; stroke: var(--grid); stroke-width: 1; } +#sky .ring.horizon { stroke: var(--axis); } +#sky .spoke { stroke: var(--grid); stroke-width: 1; } +#sky .card-pt { fill: var(--muted); font-size: 10px; text-anchor: middle; dominant-baseline: central; } +#sky .el { fill: var(--muted); font-size: 8px; } +#sky .sat { stroke: var(--surface); stroke-width: 2; cursor: default; } +#sky .sat.hollow { fill: var(--surface) !important; stroke-width: 2; } +#sky .prn { fill: var(--text-2); font-size: 7.5px; text-anchor: middle; pointer-events: none; } +.sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%; vertical-align: -1px; border: 2px solid var(--text-2); } +.sw.filled { background: var(--text-2); } +.const td:first-child { display: flex; align-items: center; gap: 6px; } +.const td i { width: 10px; height: 10px; border-radius: 50%; display: inline-block; } + +/* SNR bars */ +.snr-head { font-size: 12px; color: var(--text-2); margin: 14px 0 6px; } +.snr { display: flex; gap: 2px; height: 130px; overflow-x: auto; overflow-y: hidden; } +.snr .col { flex: 1 0 16px; max-width: 28px; display: flex; flex-direction: column; height: 100%; } +.snr .v { font-size: 10px; color: var(--text-2); text-align: center; font-variant-numeric: tabular-nums; height: 14px; } +.snr .track { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; border-bottom: 1px solid var(--axis); } +.snr .fill { border-radius: 4px 4px 0 0; min-height: 2px; } +.snr .col.unused .fill { opacity: .35; } +.snr .col:hover .fill { filter: brightness(1.15); } +.snr .lab { font-size: 10px; color: var(--muted); text-align: center; padding-top: 2px; font-variant-numeric: tabular-nums; } + +.foot { text-align: center; font-size: 12px; padding: 4px 16px 20px; } +.sources .sub2 { font-size: 11px; color: var(--muted); } -- cgit v1.2.3