The Cuckoo Escapement: field report, kernel patch, dashboard

What the Raspberry Pi time-server guides get wrong on a Pi 4, with the
measurements. The headline artifact is a four-line pps-gpio patch: PREEMPT_RT
force-threads IRQ handlers, and pps-gpio takes its timestamp inside its handler,
so the realtime kernel puts a scheduler between the electrical edge and the
clock. IRQF_NO_THREAD takes RMS offset from 2468 ns to 199 ns.

- kernel/     the patch
- dashboard/  live status page (position hidden by default)
- docs-site/  the write-up (Astro/Starlight, brass, no tutorial section)
This commit is contained in:
Ryan Malloy 2026-07-14 09:21:25 -06:00
commit 6881489bf6
56 changed files with 10297 additions and 0 deletions

13
dashboard/.env.example Normal file
View file

@ -0,0 +1,13 @@
# Copy to .env. Nothing site-specific belongs in the Makefile.
PI=deploy@gps-ntp.local
KEY=~/.ssh/id_ed25519
APPDIR=/opt/gpsntp-dashboard
# Only needed if you front the dashboard with TLS via `make caddy`.
DOMAIN=clock.example.internal
CERT_SRC=$(HOME)/.certs/clock.example.internal
# hidden (default) | coarse (~11 km) | exact
# The receiver knows exactly where it is. `hidden` keeps that out of the page —
# and out of any screenshot you post. Think before you change this.
GPSNTP_POSITION=hidden

52
dashboard/Makefile Normal file
View file

@ -0,0 +1,52 @@
# gpsntp-dashboard — deploy to the Pi.
#
# Everything site-specific lives in .env (copy .env.example). Nothing in this
# file names a host, a domain, or a certificate path.
-include .env
export
PI ?= deploy@gps-ntp.local
KEY ?= ~/.ssh/id_ed25519
APPDIR ?= /opt/gpsntp-dashboard
DOMAIN ?= clock.example.internal
SSH = ssh -i $(KEY) $(PI)
RSYNC = rsync -az -e "ssh -i $(KEY)"
# Where your Let's Encrypt live/ directory is on THIS machine, and where the
# cert should land on the Pi. Only needed if you front the dashboard with TLS.
CERT_SRC ?= $(HOME)/.certs/$(DOMAIN)
CERT_DST ?= /etc/caddy/certs/$(DOMAIN)
.PHONY: deploy logs restart status lint run caddy cert-sync
deploy:
$(RSYNC) --delete --exclude '.venv' --exclude '.git' --exclude '__pycache__' --exclude 'dist' ./ $(PI):/tmp/gpsntp-src/
$(SSH) 'sudo mkdir -p $(APPDIR) && sudo rsync -a --delete --exclude .venv /tmp/gpsntp-src/ $(APPDIR)/ && sudo APPDIR=$(APPDIR) bash $(APPDIR)/deploy/deploy.sh'
logs:
$(SSH) 'journalctl -u gpsntp-dashboard -n 60 -f'
restart:
$(SSH) 'sudo systemctl restart gpsntp-dashboard'
status:
$(SSH) 'systemctl status gpsntp-dashboard --no-pager; curl -s localhost:8080/healthz'
# Renders deploy/Caddyfile.tmpl with $(DOMAIN) and installs it.
caddy:
sed 's|{{DOMAIN}}|$(DOMAIN)|g' deploy/Caddyfile.tmpl > /tmp/Caddyfile.rendered
$(RSYNC) /tmp/Caddyfile.rendered $(PI):/tmp/Caddyfile
$(SSH) 'sudo cp /tmp/Caddyfile /etc/caddy/Caddyfile && sudo caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile && sudo systemctl reload caddy && echo reloaded'
# Push a renewed cert to the Pi. The Pi issues nothing itself — see
# deploy/Caddyfile.tmpl for why.
cert-sync:
$(RSYNC) -L $(CERT_SRC)/fullchain.pem $(CERT_SRC)/privkey.pem $(PI):/tmp/
$(SSH) 'sudo mv /tmp/fullchain.pem /tmp/privkey.pem $(CERT_DST)/ && sudo chown -R caddy:caddy /etc/caddy/certs && sudo chmod 600 $(CERT_DST)/privkey.pem && sudo systemctl reload caddy && echo "cert synced + caddy reloaded"'
lint:
uvx ruff check src/
run:
GPSD_HOST=$${GPSD_HOST:-gps-ntp.local} uv run gpsntp-dashboard

View file

@ -0,0 +1,11 @@
# gps-ntp dashboard front door. `make caddy` renders {{DOMAIN}} from .env.
#
# The cert is issued OFF-BOX and synced to the Pi (see `make cert-sync`) rather
# than obtained by Caddy itself. A time server usually lives on a LAN with no
# inbound reachability, so HTTP-01 can't work; use DNS-01 wherever you already
# run ACME and copy the result here.
{{DOMAIN}} {
tls /etc/caddy/certs/{{DOMAIN}}/fullchain.pem /etc/caddy/certs/{{DOMAIN}}/privkey.pem
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}

View file

@ -0,0 +1,10 @@
# systemd drop-in: /etc/systemd/system/caddy.service.d/affinity.conf
#
# Keep Caddy off cpu0. The PPS interrupt is handled there and cannot be moved
# (Pi 4 GPIO IRQs are demuxed via pinctrl-bcm2835 and refuse an smp_affinity),
# so any work scheduled on cpu0 adds jitter to the PPS timestamp directly.
# Caddy is mostly idle, but on this box cpu0 belongs to the clock. See
# TIMING-NOTES.md.
[Service]
CPUAffinity=1
Nice=10

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Idempotent install/update of the gps-ntp dashboard. Run on the Pi as root
# from the synced source tree (APPDIR). Safe to re-run.
set -euo pipefail
APPDIR="${APPDIR:-/opt/gpsntp-dashboard}"
SVC=gpsntp-dashboard
echo "==> dedicated system user"
id -u gpsntp &>/dev/null || useradd --system --no-create-home --shell /usr/sbin/nologin gpsntp
echo "==> python venv + editable install"
if [ ! -x "$APPDIR/.venv/bin/python" ]; then
if ! python3 -m venv "$APPDIR/.venv" 2>/dev/null; then
apt-get update -qq && apt-get install -y python3-venv python3-pip
python3 -m venv "$APPDIR/.venv"
fi
fi
"$APPDIR/.venv/bin/pip" install --quiet --upgrade pip
"$APPDIR/.venv/bin/pip" install --quiet -e "$APPDIR"
echo "==> narrow sudoers rule (clients command only)"
install -m 0440 "$APPDIR/deploy/gpsntp-dashboard.sudoers" /etc/sudoers.d/gpsntp-dashboard
visudo -cf /etc/sudoers.d/gpsntp-dashboard
echo "==> systemd unit"
install -m 0644 "$APPDIR/deploy/gpsntp-dashboard.service" "/etc/systemd/system/${SVC}.service"
echo "==> ownership"
chown -R gpsntp:gpsntp "$APPDIR"
echo "==> enable + (re)start"
systemctl daemon-reload
systemctl enable "${SVC}.service" >/dev/null 2>&1 || true
systemctl restart "${SVC}.service"
sleep 2
systemctl is-active "${SVC}.service" && echo "==> dashboard active on :8080"

View file

@ -0,0 +1,35 @@
[Unit]
Description=gps-ntp status dashboard
Documentation=https://git.supported.systems/time-pi
After=network-online.target gpsd.service chrony.service
Wants=network-online.target
[Service]
Type=exec
User=gpsntp
Group=gpsntp
ExecStart=/opt/gpsntp-dashboard/.venv/bin/gpsntp-dashboard
Environment=GPSNTP_PORT=8080
Environment=GPSNTP_HOST=0.0.0.0
Restart=on-failure
RestartSec=3
# Keep the dashboard OFF cpu0. The PPS interrupt is handled on cpu0 and cannot
# be moved (Pi 4 GPIO IRQs are demuxed through pinctrl-bcm2835 and refuse an
# smp_affinity), so any load there directly adds jitter to the timestamp.
# Measured: the dashboard cost ~36% more PPS jitter before this. It is a
# monitoring tool; it must never perturb the clock it is watching.
CPUAffinity=1
Nice=10
# Hardening. NoNewPrivileges is intentionally NOT set: the served-clients
# panel shells `sudo -n chronyc -c clients`, allowed by a narrow sudoers rule.
PrivateTmp=true
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
RestrictSUIDSGID=false
LockPersonality=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,4 @@
# Least-privilege: the dashboard user may run ONLY this one read-only command.
# `chronyc -c clients` is privileged (returns "501 Not authorised" otherwise),
# and this is the served-client list the dashboard displays. Nothing else.
gpsntp ALL=(root) NOPASSWD: /usr/bin/chronyc -c clients

29
dashboard/pyproject.toml Normal file
View file

@ -0,0 +1,29 @@
[project]
name = "gpsntp-dashboard"
version = "2026.07.12"
description = "Live status dashboard for a GPS/PPS-disciplined chrony Stratum 1 time server"
readme = "README.md"
requires-python = ">=3.11"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
license = {text = "MIT"}
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
]
[project.scripts]
gpsntp-dashboard = "gpsntp_dashboard.main:run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/gpsntp_dashboard"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]

View file

@ -0,0 +1,3 @@
"""Live status dashboard for a GPS/PPS-disciplined chrony Stratum 1 time server."""
__version__ = "2026.07.12"

View file

@ -0,0 +1,144 @@
"""Read chronyd state via `chronyc -c` (CSV) and shape it for the dashboard."""
from __future__ import annotations
import asyncio
# `chronyc -c sources` state/mode codes -> human meaning.
_MODE = {"^": "server", "=": "peer", "#": "refclock"}
_STATE = {
"*": "selected",
"+": "combined",
"-": "not_combined",
"?": "unreachable",
"x": "falseticker",
"~": "unstable",
}
async def _run(*args: str, stdin: bytes | None = None) -> str:
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate(stdin)
if proc.returncode != 0:
raise RuntimeError(err.decode(errors="replace").strip() or "command failed")
return out.decode(errors="replace")
async def collect() -> dict:
"""Fetch tracking + sources + sourcestats in a SINGLE chronyc process.
chronyc reads commands from stdin, so one fork serves all three. This
matters: forking a process per metric per second measurably degraded PPS
jitter (~36%), because the churn lands on the same CPU that handles the
PPS interrupt -- and that IRQ can't be moved off it (GPIO mux). The three
outputs are told apart by field count: 14=tracking, 10=sources, 8=stats.
"""
text = await _run("chronyc", "-c", stdin=b"tracking\nsources\nsourcestats\n")
out: dict = {"tracking": None, "sources": [], "sourcestats": {}}
for line in text.splitlines():
c = line.split(",")
if len(c) >= 14 and out["tracking"] is None:
out["tracking"] = _parse_tracking(c)
elif len(c) == 10:
out["sources"].append(_parse_source(c))
elif len(c) == 8:
out["sourcestats"][c[0]] = _parse_sourcestat(c)
return out
def _f(value: str) -> float | None:
try:
return float(value)
except (ValueError, TypeError):
return None
def _parse_tracking(c: list[str]) -> dict:
"""Reference, stratum, offsets, root delay/dispersion."""
return {
"ref_id": c[0],
"ref_name": c[1],
"stratum": int(c[2]) if c[2].isdigit() else None,
"ref_time": _f(c[3]),
"system_time": _f(c[4]),
"last_offset": _f(c[5]),
"rms_offset": _f(c[6]),
"frequency_ppm": _f(c[7]),
"residual_freq_ppm": _f(c[8]),
"skew_ppm": _f(c[9]),
"root_delay": _f(c[10]),
"root_dispersion": _f(c[11]),
"update_interval": _f(c[12]),
"leap_status": c[13],
}
def _parse_source(c: list[str]) -> dict:
state = _STATE.get(c[1], c[1])
try:
reach = int(c[5], 8) # chrony reports the reachability register in octal
except ValueError:
reach = None
# chrony reports '?' both for a genuinely unreachable source AND for one it
# simply isn't considering -- notably our GPS refclock, which is `noselect`
# (it exists only to tell PPS which second it is, never to be chosen). If
# samples are still arriving (reach > 0) the source is plainly NOT
# unreachable, so don't cry wolf: call it what it is, a reference.
# Flagging a healthy source red trains you to ignore red.
if state == "unreachable" and reach:
state = "reference_only"
return {
"mode": _MODE.get(c[0], c[0]),
"state": state,
"name": c[2],
"stratum": int(c[3]) if c[3].isdigit() else None,
"poll": int(c[4]) if c[4].lstrip("-").isdigit() else None,
"reach": reach,
"last_rx": int(c[6]) if c[6].lstrip("-").isdigit() else None,
"offset": _f(c[7]),
"offset_measured": _f(c[8]),
"error": _f(c[9]),
}
def _parse_sourcestat(c: list[str]) -> dict:
return {
"samples": int(c[1]) if c[1].isdigit() else None,
"runs": int(c[2]) if c[2].isdigit() else None,
"span": int(c[3]) if c[3].isdigit() else None,
"frequency_ppm": _f(c[4]),
"freq_skew_ppm": _f(c[5]),
"offset": _f(c[6]),
"std_dev": _f(c[7]),
}
async def clients() -> list[dict]:
"""Served NTP clients. Privileged, so try sudo -n; degrade quietly if denied."""
for cmd in (("chronyc", "-c", "clients"), ("sudo", "-n", "chronyc", "-c", "clients")):
try:
text = await _run(*cmd)
except RuntimeError:
continue
rows = []
for line in text.splitlines():
c = line.split(",")
if len(c) < 2 or not c[0]:
continue
rows.append(
{
"address": c[0],
"ntp_requests": int(c[1]) if c[1].isdigit() else None,
"ntp_dropped": int(c[2]) if len(c) > 2 and c[2].isdigit() else None,
"last_seen": int(c[4]) if len(c) > 4 and c[4].lstrip("-").isdigit() else None,
}
)
return rows
return []

View file

@ -0,0 +1,165 @@
"""Async gpsd client: streams JSON, keeps the latest fix (TPV) and sky (SKY)."""
from __future__ import annotations
import asyncio
import json
import os
# How much of the receiver's position to expose. DEFAULT IS "hidden", on purpose.
#
# A time server is stationary and its position contributes nothing to time
# accuracy -- so the coordinates are operationally useless here. But people
# screenshot dashboards and paste them into forums and issues, and a lat/lon to
# 7 decimal places is their home address. Leaking something sensitive to display
# something nobody needs is a bad trade. Opt in if you actually want it.
#
# hidden (default) : no coordinates; just "position locked"
# coarse : rounded to ~11 km -- enough to sanity-check the region
# exact : full precision
POSITION_MODE = os.environ.get("GPSNTP_POSITION", "hidden").strip().lower()
# gpsd gnssid -> constellation name (used for colouring the sky plot).
CONSTELLATION = {
0: "GPS",
1: "SBAS",
2: "Galileo",
3: "BeiDou",
4: "IMES",
5: "QZSS",
6: "GLONASS",
7: "NavIC",
}
_FIX_MODE = {0: "unknown", 1: "none", 2: "2D", 3: "3D"}
# TPV status: 2 = DGPS, 3 = RTK fixed, 4 = RTK float (best-effort labels).
_FIX_STATUS = {0: "unknown", 1: "GPS", 2: "DGPS", 3: "RTK-fixed", 4: "RTK-float"}
class GpsdReader:
"""Connect to gpsd, WATCH, and cache the newest TPV + SKY reports."""
def __init__(self, host: str = "127.0.0.1", port: int = 2947):
self.host = host
self.port = port
self.tpv: dict = {}
self.satellites: list[dict] = []
self.dop: dict = {}
self.connected = False
self._task: asyncio.Task | None = None
def start(self) -> None:
self._task = asyncio.create_task(self._run())
async def stop(self) -> None:
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run(self) -> None:
while True:
try:
await self._read_loop()
except asyncio.CancelledError:
raise
except Exception:
self.connected = False
await asyncio.sleep(2) # gpsd down / restarting; retry
async def _read_loop(self) -> None:
reader, writer = await asyncio.open_connection(self.host, self.port)
self.connected = True
writer.write(b'?WATCH={"enable":true,"json":true}\n')
await writer.drain()
try:
while True:
line = await reader.readline()
if not line:
break
self._ingest(line)
finally:
self.connected = False
writer.close()
def _ingest(self, line: bytes) -> None:
try:
msg = json.loads(line)
except json.JSONDecodeError:
return
cls = msg.get("class")
if cls == "TPV":
# gpsd sends PARTIAL TPV reports — a frame may omit "mode", "lat",
# etc. Overwriting the cache would drop the fix state and render it
# as "unknown". Merge instead, so known fields survive until gpsd
# actually supersedes them. (Same trap as the DOP-only SKY frames.)
self.tpv.update(msg)
elif cls == "SKY":
# DOP-only SKY frames omit satellites[] — keep the last real list.
sats = msg.get("satellites")
if sats is not None:
self.satellites = sats
for k in ("hdop", "vdop", "pdop", "gdop", "tdop", "uSat", "nSat"):
if k in msg:
self.dop[k] = msg[k]
def _fix_status(self, mode: int) -> str:
"""TPV omits `status` when there's no augmentation (e.g. SBAS disabled).
An absent status on a valid fix means plain GPS, NOT 'unknown' -- the
absence of *extra* information is not the absence of information.
"""
status = self.tpv.get("status")
if not status: # None or 0
return "GPS" if mode >= 2 else "none"
return _FIX_STATUS.get(status, "GPS")
def _position(self) -> dict:
"""Expose position per POSITION_MODE. See the note at the top of this file:
hidden by default, because a screenshotted dashboard should not publish
the operator's home address to display a number nobody needs.
"""
lat, lon = self.tpv.get("lat"), self.tpv.get("lon")
has_fix = lat is not None and lon is not None
if not has_fix or POSITION_MODE == "hidden":
return {"lat": None, "lon": None, "has_position": has_fix,
"position_mode": POSITION_MODE}
if POSITION_MODE == "coarse":
lat, lon = round(lat, 1), round(lon, 1) # ~11 km — region, not street
return {"lat": lat, "lon": lon, "has_position": True,
"position_mode": POSITION_MODE}
def snapshot(self) -> dict:
t = self.tpv
mode = t.get("mode", 0)
sats = [
{
"prn": s.get("PRN"),
"az": s.get("az"),
"el": s.get("el"),
"ss": s.get("ss"),
"used": bool(s.get("used")),
"constellation": CONSTELLATION.get(s.get("gnssid"), "other"),
}
for s in self.satellites
if s.get("az") is not None and s.get("el") is not None
]
return {
"connected": self.connected,
"device": t.get("device"),
"fix_mode": _FIX_MODE.get(mode, "unknown"),
"fix_status": self._fix_status(mode),
**self._position(), # lat/lon gated by GPSNTP_POSITION (default: hidden)
"alt_m": t.get("altMSL", t.get("alt")),
"time": t.get("time"),
"leapseconds": t.get("leapseconds"),
"ept": t.get("ept"),
"epx": t.get("epx"),
"epy": t.get("epy"),
"hdop": self.dop.get("hdop"),
"pdop": self.dop.get("pdop"),
"sats_used": sum(1 for s in sats if s["used"]),
"sats_seen": len(sats),
"satellites": sats,
}

View file

@ -0,0 +1,158 @@
"""FastAPI app: one background collector feeds the dashboard, /api, /metrics, /ws."""
from __future__ import annotations
import asyncio
import contextlib
import os
import time
from collections import deque
from importlib.metadata import version
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from . import chrony, metrics
from .gpsd_reader import GpsdReader
STATIC = Path(__file__).parent / "static"
POLL_SECONDS = float(os.environ.get("GPSNTP_POLL_SECONDS", "1"))
HISTORY_LEN = int(os.environ.get("GPSNTP_HISTORY_POINTS", "180")) # ~3 min at 1s
CLIENTS_EVERY = 5 # sample the privileged clients list every Nth cycle
gpsd = GpsdReader(
host=os.environ.get("GPSD_HOST", "127.0.0.1"),
port=int(os.environ.get("GPSD_PORT", "2947")),
)
# Shared state produced by the single collector task.
_history: deque[dict] = deque(maxlen=HISTORY_LEN)
_state: dict = {"snapshot": {"version": "?", "history": []}}
async def _build(include_clients: bool, prev_clients) -> dict:
"""One pass over chrony + gpsd. Each source degrades independently."""
snap: dict = {"gps": gpsd.snapshot(), "version": _state["snapshot"]["version"],
"server_time": time.time()}
# ONE chronyc process for tracking+sources+sourcestats. Forking a process
# per metric per second measurably worsened PPS jitter (~36%) -- the churn
# lands on the CPU that handles the PPS interrupt. See chrony.collect().
try:
snap.update(await chrony.collect())
except Exception as exc:
snap["tracking"] = snap["sources"] = snap["sourcestats"] = None
snap.setdefault("errors", {})["chrony"] = str(exc)
if include_clients:
try:
snap["clients"] = await chrony.clients()
except Exception as exc:
snap["clients"] = None
snap.setdefault("errors", {})["clients"] = str(exc)
else:
snap["clients"] = prev_clients
return snap
async def _collect_loop() -> None:
_state["snapshot"]["version"] = version("gpsntp-dashboard")
i = 0
prev_clients = None
while True:
try:
snap = await _build(include_clients=(i % CLIENTS_EVERY == 0), prev_clients=prev_clients)
prev_clients = snap.get("clients")
t = snap.get("tracking") or {}
srcs = snap.get("sources") or []
pps = next((s.get("offset") for s in srcs if s["name"] == "PPS"), None)
_history.append({"t": snap["server_time"], "sys": t.get("system_time"), "pps": pps})
snap["history"] = list(_history)
_state["snapshot"] = snap
except Exception:
pass
i += 1
await asyncio.sleep(POLL_SECONDS)
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
gpsd.start()
task = asyncio.create_task(_collect_loop())
yield
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await gpsd.stop()
app = FastAPI(title="gps-ntp dashboard", version=version("gpsntp-dashboard"), lifespan=lifespan)
@app.get("/api/status")
async def api_status() -> JSONResponse:
return JSONResponse(_state["snapshot"])
@app.get("/metrics")
async def prometheus() -> PlainTextResponse:
return PlainTextResponse(metrics.render(_state["snapshot"]), media_type=metrics.CONTENT_TYPE)
@app.get("/healthz")
async def healthz() -> dict:
return {"ok": True, "gpsd_connected": gpsd.connected}
@app.websocket("/ws")
async def ws(socket: WebSocket) -> None:
await socket.accept()
try:
while True:
await socket.send_json(_state["snapshot"])
await asyncio.sleep(POLL_SECONDS)
except WebSocketDisconnect:
pass
@app.get("/")
async def index() -> FileResponse:
return FileResponse(STATIC / "index.html")
class RevalidatingStatic(StaticFiles):
"""StaticFiles, but the browser must ask before reusing a cached copy.
Starlette sends an ETag and Last-Modified but no Cache-Control, so browsers
fall back to *heuristic* freshness (RFC 9111 4.2.2) and happily serve a
stale style.css for hours. Every deploy replaces these files at the same
URLs, so that means a dashboard someone left open shows old markup against
old CSS which is exactly how we shipped an unstyled footer once.
`no-cache` doesn't mean "don't cache", it means "revalidate before use".
The ETag still turns that into a 304 with an empty body, so the cost is one
conditional request for ~30 KB of assets on a LAN. Cheap; correct.
"""
def is_not_modified(self, response_headers, request_headers) -> bool: # noqa: ANN001
response_headers["cache-control"] = "no-cache"
return super().is_not_modified(response_headers, request_headers)
async def get_response(self, path: str, scope): # noqa: ANN001, ANN201
response = await super().get_response(path, scope)
response.headers["cache-control"] = "no-cache"
return response
app.mount("/static", RevalidatingStatic(directory=STATIC), name="static")
def run() -> None:
import uvicorn
uvicorn.run(
"gpsntp_dashboard.main:app",
host=os.environ.get("GPSNTP_HOST", "0.0.0.0"),
port=int(os.environ.get("GPSNTP_PORT", "8080")),
log_level="info",
)

View file

@ -0,0 +1,88 @@
"""Render a status snapshot as Prometheus text exposition format (0.0.4)."""
from __future__ import annotations
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
_FIX_MODE_NUM = {"none": 0, "2D": 2, "3D": 3, "unknown": 0}
def _esc(v: str) -> str:
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
def _labels(pairs: dict) -> str:
inner = ",".join(f'{k}="{_esc(v)}"' for k, v in pairs.items() if v is not None)
return f"{{{inner}}}" if inner else ""
def render(snap: dict) -> str:
out: list[str] = []
def metric(name: str, mtype: str, help_: str, samples: list[tuple[dict, float]]):
vals = [(lbls, v) for lbls, v in samples if v is not None]
if not vals:
return
out.append(f"# HELP {name} {help_}")
out.append(f"# TYPE {name} {mtype}")
for lbls, v in vals:
out.append(f"{name}{_labels(lbls)} {v}")
metric("gpsntp_up", "gauge", "Dashboard collector produced a snapshot", [({}, 1)])
g = snap.get("gps") or {}
metric("gpsntp_gpsd_connected", "gauge", "gpsd socket connected",
[({}, 1 if g.get("connected") else 0)])
metric("gpsntp_gps_fix_mode", "gauge", "GPS fix mode (0 none, 2 2D, 3 3D)",
[({}, _FIX_MODE_NUM.get(g.get("fix_mode"), 0))])
metric("gpsntp_gps_satellites_used", "gauge", "Satellites used in the fix",
[({}, g.get("sats_used"))])
metric("gpsntp_gps_satellites_visible", "gauge", "Satellites visible",
[({}, g.get("sats_seen"))])
metric("gpsntp_gps_hdop", "gauge", "Horizontal dilution of precision",
[({}, g.get("hdop"))])
t = snap.get("tracking") or {}
metric("gpsntp_stratum", "gauge", "Stratum of this server", [({}, t.get("stratum"))])
metric("gpsntp_system_offset_seconds", "gauge", "System clock offset from true time",
[({}, t.get("system_time"))])
metric("gpsntp_last_offset_seconds", "gauge", "Last measured offset",
[({}, t.get("last_offset"))])
metric("gpsntp_rms_offset_seconds", "gauge", "RMS offset", [({}, t.get("rms_offset"))])
metric("gpsntp_frequency_ppm", "gauge", "Clock frequency correction",
[({}, t.get("frequency_ppm"))])
metric("gpsntp_skew_ppm", "gauge", "Estimated frequency skew", [({}, t.get("skew_ppm"))])
metric("gpsntp_root_delay_seconds", "gauge", "Total root delay to the reference",
[({}, t.get("root_delay"))])
metric("gpsntp_root_dispersion_seconds", "gauge", "Total root dispersion",
[({}, t.get("root_dispersion"))])
if t.get("ref_name"):
metric("gpsntp_reference_info", "gauge", "Current reference (value always 1)",
[({"ref_id": t.get("ref_id"), "ref_name": t.get("ref_name")}, 1)])
sources = snap.get("sources") or []
stats = snap.get("sourcestats") or {}
metric("gpsntp_source_offset_seconds", "gauge", "Per-source last offset",
[({"name": s["name"], "mode": s["mode"], "state": s["state"]}, s.get("offset"))
for s in sources])
metric("gpsntp_source_stratum", "gauge", "Per-source stratum",
[({"name": s["name"]}, s.get("stratum")) for s in sources])
metric("gpsntp_source_reach", "gauge", "Per-source reachability register (0-255)",
[({"name": s["name"]}, s.get("reach")) for s in sources])
metric("gpsntp_source_last_rx_seconds", "gauge", "Seconds since last sample from source",
[({"name": s["name"]}, s.get("last_rx")) for s in sources])
metric("gpsntp_source_std_dev_seconds", "gauge", "Per-source estimated std deviation",
[({"name": n}, st.get("std_dev")) for n, st in stats.items()])
metric("gpsntp_satellite_snr_db", "gauge", "Per-satellite carrier-to-noise density",
[({"prn": s.get("prn"), "constellation": s.get("constellation"),
"used": "true" if s.get("used") else "false"}, s.get("ss"))
for s in (g.get("satellites") or []) if s.get("ss")])
clients = snap.get("clients")
if clients is not None:
metric("gpsntp_clients_total", "gauge", "Number of NTP clients seen", [({}, len(clients))])
metric("gpsntp_client_ntp_requests_total", "counter", "NTP requests per client",
[({"address": c["address"]}, c.get("ntp_requests")) for c in clients])
return "\n".join(out) + "\n"

View file

@ -0,0 +1,295 @@
"use strict";
const CONSTS = {
GPS: getComputedStyle(document.documentElement).getPropertyValue("--c-gps").trim(),
GLONASS: cssVar("--c-glonass"),
Galileo: cssVar("--c-galileo"),
BeiDou: cssVar("--c-beidou"),
SBAS: cssVar("--c-sbas"),
QZSS: cssVar("--c-qzss"),
other: cssVar("--c-other"),
};
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
function color(constellation) { return CONSTS[constellation] || CONSTS.other; }
const $ = (id) => document.getElementById(id);
// ---- clock (interpolated from the server's disciplined time) ----
let clockBase = null; // { serverMs, perfMs }
function tickClock() {
let d;
if (clockBase) {
d = new Date(clockBase.serverMs + (performance.now() - clockBase.perfMs));
} else {
d = new Date();
}
const p = (n, w = 2) => String(n).padStart(w, "0");
$("utc-clock").firstChild.nodeValue =
`${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
$("utc-frac").textContent = "." + p(d.getUTCMilliseconds(), 3);
$("utc-date").textContent = d.toISOString().slice(0, 10);
requestAnimationFrame(tickClock);
}
requestAnimationFrame(tickClock);
// ---- formatting ----
function fmtOffset(sec) {
if (sec === null || sec === undefined) return "—";
const a = Math.abs(sec);
const sign = sec < 0 ? "" : "+";
if (a < 1e-6) return `${sign}${(a * 1e9).toFixed(0)} ns`;
if (a < 1e-3) return `${sign}${(a * 1e6).toFixed(2)} µs`;
if (a < 1) return `${sign}${(a * 1e3).toFixed(3)} ms`;
return `${sign}${a.toFixed(3)} s`;
}
function fmtAbs(sec) {
if (sec === null || sec === undefined) return "—";
const a = Math.abs(sec);
if (a < 1e-6) return `${(a * 1e9).toFixed(1)} ns`;
if (a < 1e-3) return `${(a * 1e6).toFixed(2)} µs`;
if (a < 1) return `${(a * 1e3).toFixed(2)} ms`;
return `${a.toFixed(3)} s`;
}
function offsetClass(sec) {
const a = Math.abs(sec ?? 1);
if (a < 1e-5) return "good";
if (a < 1e-2) return "warn";
return "bad";
}
// ---- render ----
function render(d) {
if (typeof d.server_time === "number") {
clockBase = { serverMs: d.server_time * 1000, perfMs: performance.now() };
}
renderTracking(d.tracking);
renderGps(d.gps);
// Real PPS jitter comes from chrony's per-source stats (nanoseconds), not
// gpsd's coarse NMEA time-error estimate.
const ppsStats = d.sourcestats && d.sourcestats["PPS"];
if (ppsStats && ppsStats.std_dev != null) {
$("pps-jitter").textContent = "±" + fmtAbs(ppsStats.std_dev);
}
renderSources(d.sources, d.tracking);
renderClients(d.clients);
renderSky(d.gps ? d.gps.satellites : []);
renderSnr(d.gps ? d.gps.satellites : []);
renderSpark(d.history);
$("foot-version").textContent = "gpsntp-dashboard v" + (d.version || "?");
$("foot-updated").textContent = "updated " + new Date().toLocaleTimeString();
}
function renderTracking(t) {
const badge = $("stratum-badge");
if (!t) { $("stratum-num").textContent = "—"; badge.dataset.ok = "false"; return; }
$("stratum-num").textContent = t.stratum ?? "—";
$("ref-name").textContent = t.ref_name || "—";
const synced = t.stratum === 1 && t.leap_status === "Normal";
badge.dataset.ok = synced ? "true" : "false";
const sys = $("sys-offset");
sys.textContent = fmtOffset(t.system_time);
sys.className = "card-value " + offsetClass(t.system_time);
$("last-offset").textContent = fmtAbs(t.last_offset);
$("root-delay").textContent = fmtAbs(t.root_delay);
$("root-disp").textContent = fmtAbs(t.root_dispersion);
$("freq").textContent = t.frequency_ppm != null ? t.frequency_ppm.toFixed(3) + " ppm" : "—";
$("skew").textContent = t.skew_ppm != null ? t.skew_ppm.toFixed(3) + " ppm" : "—";
}
function renderGps(g) {
if (!g) return;
const fix = $("fix-mode");
fix.textContent = g.fix_mode === "3D" ? (g.fix_status || "3D") : (g.fix_mode || "—");
fix.className = "card-value " + (g.fix_mode === "3D" ? "good" : g.fix_mode === "2D" ? "warn" : "bad");
$("sats-line").textContent = `${g.sats_used}/${g.sats_seen} sats`;
$("hdop").textContent = g.hdop != null ? g.hdop.toFixed(2) : "—";
// Position is HIDDEN by default (GPSNTP_POSITION). A stationary time server's
// coordinates do nothing for time accuracy, but a screenshotted dashboard with
// 7 decimal places is somebody's home address. Confirm the fix, don't dox them.
const pin = `<svg aria-hidden="true" class="ic-inline"><use href="#i-pin"/></svg> `;
const loc = $("location");
if (g.lat != null && g.lon != null) {
const dp = g.position_mode === "coarse" ? 1 : 4;
loc.innerHTML = pin + `${g.lat.toFixed(dp)}, ${g.lon.toFixed(dp)}` +
(g.position_mode === "coarse" ? " <span class=\"pos-note\">approx</span>" : "");
} else if (g.has_position) {
loc.innerHTML = pin + "position locked";
} else {
loc.innerHTML = pin + "no position";
}
// PPS card: pull the PPS source stats via the sources render; here show fix time error.
const pps = $("pps-state");
if (g.fix_mode === "3D") { pps.textContent = "locked"; pps.className = "card-value good"; }
else { pps.textContent = "waiting"; pps.className = "card-value warn"; }
$("pps-jitter").textContent = g.ept != null ? "±" + fmtAbs(g.ept) : "—";
}
function renderSources(sources, tracking) {
const tb = $("sources").querySelector("tbody");
tb.innerHTML = "";
if (!sources) { tb.innerHTML = `<tr><td colspan="7" class="empty-note">chronyd unavailable</td></tr>`; return; }
for (const s of sources) {
const tr = document.createElement("tr");
if (s.state === "selected") tr.className = "sel";
if (s.mode === "refclock") tr.classList.add("refclk");
const reach = s.reach === 255 ? "377" : (s.reach ?? "—");
tr.innerHTML =
`<td>${esc(s.name)}</td>` +
`<td>${s.mode}</td>` +
`<td class="num">${s.stratum ?? "—"}</td>` +
`<td class="num">${reach}</td>` +
`<td class="num">${s.last_rx != null ? s.last_rx + "s" : "—"}</td>` +
`<td class="num">${fmtOffset(s.offset)}</td>` +
`<td><span class="pill ${s.state}">${s.state.replace("_", " ")}</span></td>`;
tb.appendChild(tr);
}
// PPS jitter into the card if PPS source present
}
function renderClients(clients) {
const el = $("clients");
$("clients-count").textContent = clients ? clients.length : "—";
if (clients === null) { el.innerHTML = `<span class="empty-note">Client list needs elevated access (not granted).</span>`; return; }
if (clients.length === 0) { el.innerHTML = `<span class="empty-note">No clients have queried yet.</span>`; return; }
el.innerHTML = "";
for (const c of clients) {
const div = document.createElement("div");
div.className = "client";
div.innerHTML = `<span class="addr">${esc(c.address)}</span>` +
`<span class="meta">${c.ntp_requests ?? 0} requests` +
`${c.last_seen != null ? " · " + c.last_seen + "s ago" : ""}</span>`;
el.appendChild(div);
}
}
// ---- sky plot ----
const SKY = { cx: 200, cy: 200, r: 178 };
function elAzToXY(el, az) {
const rr = SKY.r * (90 - Math.max(0, Math.min(90, el))) / 90;
const a = (az * Math.PI) / 180;
return [SKY.cx + rr * Math.sin(a), SKY.cy - rr * Math.cos(a)];
}
function svgEl(tag, attrs) {
const e = document.createElementNS("http://www.w3.org/2000/svg", tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function drawSkyFrame(svg) {
for (const [el, cls] of [[0, "sky-ring"], [30, "sky-ring faint"], [60, "sky-ring faint"]]) {
const rr = SKY.r * (90 - el) / 90;
svg.appendChild(svgEl("circle", { cx: SKY.cx, cy: SKY.cy, r: rr, class: cls }));
}
svg.appendChild(svgEl("line", { x1: SKY.cx, y1: SKY.cy - SKY.r, x2: SKY.cx, y2: SKY.cy + SKY.r, class: "sky-cross" }));
svg.appendChild(svgEl("line", { x1: SKY.cx - SKY.r, y1: SKY.cy, x2: SKY.cx + SKY.r, y2: SKY.cy, class: "sky-cross" }));
for (const [lbl, x, y] of [["N", SKY.cx, SKY.cy - SKY.r - 5], ["S", SKY.cx, SKY.cy + SKY.r + 13],
["E", SKY.cx + SKY.r + 8, SKY.cy + 4], ["W", SKY.cx - SKY.r - 8, SKY.cy + 4]]) {
const t = svgEl("text", { x, y, class: "sky-card-label", "text-anchor": "middle" });
t.textContent = lbl; svg.appendChild(t);
}
const t30 = svgEl("text", { x: SKY.cx + 4, y: SKY.cy - SKY.r * (60 / 90) - 3, class: "sky-ring-label" });
t30.textContent = "30°"; svg.appendChild(t30);
}
function renderSky(sats) {
const svg = $("sky");
svg.innerHTML = "";
drawSkyFrame(svg);
for (const s of sats || []) {
if (s.el == null || s.az == null) continue;
const [x, y] = elAzToXY(s.el, s.az);
const rad = 4 + (s.ss ? Math.min(9, s.ss / 6) : 1.5);
const col = color(s.constellation);
svg.appendChild(svgEl("circle", { cx: x, cy: y, r: rad + 4, fill: col, class: "sat-halo" }));
const dot = svgEl("circle", {
cx: x, cy: y, r: rad, class: "sat",
fill: s.used ? col : "transparent", stroke: col, "stroke-width": s.used ? 0 : 1.6,
});
dot.addEventListener("pointerenter", (e) => showTip(e, s));
dot.addEventListener("pointerleave", hideTip);
svg.appendChild(dot);
if (s.used && rad >= 6 && s.prn != null) {
const lbl = svgEl("text", { x, y: y + 3, class: "sat-label", "text-anchor": "middle" });
lbl.textContent = s.prn; svg.appendChild(lbl);
}
}
renderLegend(sats || []);
}
function renderLegend(sats) {
const present = [...new Set(sats.map((s) => s.constellation))];
const order = ["GPS", "GLONASS", "Galileo", "BeiDou", "SBAS", "QZSS", "other"];
present.sort((a, b) => order.indexOf(a) - order.indexOf(b));
$("legend").innerHTML = present.map((c) =>
`<span><i style="background:${color(c)}"></i>${c}</span>`).join("");
}
function showTip(e, s) {
const tip = $("sky-tip");
const hold = tip.parentElement.getBoundingClientRect();
tip.hidden = false;
tip.innerHTML = `${s.constellation} #${s.prn ?? "?"}<br>el ${Math.round(s.el)}° · az ${Math.round(s.az)}°` +
`<br>${s.ss != null ? s.ss + " dB-Hz" : "no signal"}${s.used ? " · in fix" : ""}`;
tip.style.left = (e.clientX - hold.left) + "px";
tip.style.top = (e.clientY - hold.top) + "px";
}
function hideTip() { $("sky-tip").hidden = true; }
// ---- snr bars ----
function renderSnr(sats) {
const el = $("snr");
const withSig = (sats || []).filter((s) => s.ss != null && s.ss > 0)
.sort((a, b) => b.ss - a.ss);
if (withSig.length === 0) { el.innerHTML = `<div class="snr-empty">No satellite signals reported.</div>`; return; }
el.innerHTML = "";
for (const s of withSig) {
const bar = document.createElement("div");
bar.className = "snr-bar" + (s.used ? " used" : "");
const h = Math.max(3, Math.min(100, (s.ss / 55) * 100));
bar.innerHTML = `<div class="snr-fill" style="height:${h}%;background:${color(s.constellation)}"></div>` +
`<div class="snr-prn">${s.prn ?? ""}</div>`;
bar.title = `${s.constellation} #${s.prn}: ${s.ss} dB-Hz${s.used ? " (used)" : ""}`;
el.appendChild(bar);
}
}
// ---- offset history sparkline ----
function renderSpark(history) {
const svg = $("spark");
const W = 1000, H = 120, pad = 10;
const pts = (history || []).filter((p) => p.sys != null);
if (pts.length < 2) { svg.innerHTML = ""; $("spark-meta").textContent = "collecting…"; return; }
const maxAbs = Math.max(2e-7, ...pts.map((p) => Math.abs(p.sys)));
const span = maxAbs * 1.2;
const zeroY = H / 2;
const xOf = (i) => pad + (i / (pts.length - 1)) * (W - 2 * pad);
const yOf = (v) => zeroY - (v / span) * (H / 2 - pad);
let line = "";
pts.forEach((p, i) => { line += (i ? "L" : "M") + xOf(i).toFixed(1) + " " + yOf(p.sys).toFixed(1) + " "; });
const area = `M ${xOf(0).toFixed(1)} ${zeroY} ` +
pts.map((p, i) => `L ${xOf(i).toFixed(1)} ${yOf(p.sys).toFixed(1)}`).join(" ") +
` L ${xOf(pts.length - 1).toFixed(1)} ${zeroY} Z`;
const a = cssVar("--accent");
svg.innerHTML =
`<defs><linearGradient id="spark-grad" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0%" stop-color="${a}" stop-opacity="0.32"/>` +
`<stop offset="100%" stop-color="${a}" stop-opacity="0"/></linearGradient></defs>` +
`<line x1="${pad}" y1="${zeroY}" x2="${W - pad}" y2="${zeroY}" class="spark-zero"/>` +
`<path d="${area}" class="spark-area"/>` +
`<path d="${line}" class="spark-line"/>`;
$("spark-meta").textContent =
`now ${fmtOffset(pts[pts.length - 1].sys)} · peak ±${fmtAbs(maxAbs)} · ${pts.length}s`;
}
function esc(s) { const d = document.createElement("div"); d.textContent = s ?? ""; return d.innerHTML; }
// ---- websocket with reconnect ----
function setConn(state, label) {
const c = $("conn"); c.dataset.state = state; $("conn-label").textContent = label;
}
function connect() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => setConn("live", "live");
ws.onmessage = (ev) => { try { render(JSON.parse(ev.data)); } catch (e) { console.error(e); } };
ws.onclose = () => { setConn("down", "reconnecting…"); setTimeout(connect, 2000); };
ws.onerror = () => ws.close();
}
connect();

View file

@ -0,0 +1,180 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>gps-ntp · time server</title>
<link rel="stylesheet" href="/static/style.css" />
<link rel="icon"
href="data:image/svg+xml,&lt;svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'&gt;&lt;text y='18' font-size='18'&gt;🛰️&lt;/text&gt;&lt;/svg&gt;" />
</head>
<body>
<!-- lucide icon sprite (inline so the dashboard works fully offline) -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="i-dish" viewBox="0 0 24 24"><path d="M4 10a7.31 7.31 0 0 0 10 10Z"/><path d="m9 15 3-3"/><path d="M17 13a6 6 0 0 0-6-6"/><path d="M21 13A10 10 0 0 0 11 3"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></symbol>
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
<symbol id="i-zap" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></symbol>
<symbol id="i-gauge" viewBox="0 0 24 24"><path d="m12 14 4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/></symbol>
<symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="i-pin" viewBox="0 0 24 24"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></symbol>
<symbol id="i-radio" viewBox="0 0 24 24"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"/><circle cx="12" cy="12" r="2"/><path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol>
</defs>
</svg>
<header class="topbar">
<div class="brand">
<svg class="brand-icon" aria-hidden="true"><use href="#i-dish"/></svg>
<div>
<h1 id="hostname">gps-ntp</h1>
<p class="brand-sub">GPS · PPS disciplined time server</p>
</div>
</div>
<div class="conn" id="conn" data-state="init">
<span class="conn-dot" aria-hidden="true"></span>
<span id="conn-label">connecting…</span>
</div>
</header>
<main>
<!-- Hero: live clock + stratum verdict -->
<section class="hero panel" aria-label="Current time and sync status">
<div class="clock-wrap">
<div class="clock" id="utc-clock">--:--:--<span class="clock-frac" id="utc-frac">.000</span></div>
<div class="clock-meta"><span id="utc-date"></span> · <span class="mono">UTC</span></div>
</div>
<div class="verdict">
<div class="stratum-badge" id="stratum-badge" data-ok="false">
<svg aria-hidden="true"><use href="#i-check"/></svg>
<div>
<span class="stratum-num" id="stratum-num"></span>
<span class="stratum-word">stratum</span>
</div>
</div>
<div class="ref-line">synced to <strong id="ref-name"></strong></div>
</div>
</section>
<!-- Key stat cards -->
<section class="cards" aria-label="Key metrics">
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-zap"/></svg> System offset</div>
<div class="card-value" id="sys-offset"></div>
<div class="card-sub">of true time · last <span id="last-offset"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-activity"/></svg> PPS lock</div>
<div class="card-value" id="pps-state"></div>
<div class="card-sub">jitter <span id="pps-jitter"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-dish"/></svg> GPS fix</div>
<div class="card-value" id="fix-mode"></div>
<div class="card-sub"><span id="sats-line"></span> · HDOP <span id="hdop"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-gauge"/></svg> Root delay</div>
<div class="card-value" id="root-delay"></div>
<div class="card-sub">dispersion <span id="root-disp"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-server"/></svg> Clock freq</div>
<div class="card-value" id="freq"></div>
<div class="card-sub">skew <span id="skew"></span></div>
</article>
<article class="card">
<div class="card-head"><svg aria-hidden="true"><use href="#i-users"/></svg> Clients served</div>
<div class="card-value" id="clients-count"></div>
<div class="card-sub" id="location"><svg aria-hidden="true" class="ic-inline"><use href="#i-pin"/></svg></div>
</article>
</section>
<!-- Offset history sparkline -->
<section class="panel spark-panel" aria-label="System offset history">
<div class="panel-head">
<h2><svg aria-hidden="true"><use href="#i-activity"/></svg> System offset · last 3 min</h2>
<div class="spark-meta" id="spark-meta"></div>
</div>
<svg id="spark" class="spark-big" viewBox="0 0 1000 120" preserveAspectRatio="none"
role="img" aria-label="System clock offset over the last three minutes"></svg>
</section>
<div class="grid-2">
<!-- Sky plot -->
<section class="panel sky-panel" aria-label="Satellite sky view">
<div class="panel-head">
<h2><svg aria-hidden="true"><use href="#i-dish"/></svg> Sky view</h2>
<div class="legend" id="legend"></div>
</div>
<div class="sky-hold">
<svg id="sky" viewBox="0 0 400 400" role="img" aria-label="Polar plot of satellites overhead"></svg>
<div class="sky-tip" id="sky-tip" hidden></div>
</div>
</section>
<!-- Signal bars -->
<section class="panel snr-panel" aria-label="Satellite signal strength">
<div class="panel-head"><h2><svg aria-hidden="true"><use href="#i-radio"/></svg> Signal (C/N₀ dB-Hz)</h2></div>
<div class="snr" id="snr"></div>
</section>
</div>
<!-- Sources -->
<section class="panel" aria-label="Time sources">
<div class="panel-head"><h2><svg aria-hidden="true"><use href="#i-server"/></svg> Time sources</h2></div>
<div class="table-hold">
<table class="tbl" id="sources">
<thead>
<tr><th>Source</th><th>Type</th><th class="num">Str</th><th class="num">Reach</th><th class="num">Last</th><th class="num">Offset</th><th>State</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Clients -->
<section class="panel" aria-label="Clients">
<div class="panel-head"><h2><svg aria-hidden="true"><use href="#i-users"/></svg> Network clients</h2></div>
<div id="clients" class="clients"></div>
</section>
</main>
<footer class="foot">
<!-- The maker's plate. Clockmakers signed the BACKPLATE — the brass face
only a repairer ever sees, once the case is open. A footer is the same
thing: a quiet engraved signature, not a billboard. -->
<aside class="ss-plate" aria-label="Supported Systems">
<a class="ss-plate__link" href="https://supported.systems" rel="noopener">
<img class="ss-plate__logo" src="/static/supported-systems-logo.svg"
alt="" width="52" height="39" loading="lazy" />
<span class="ss-plate__copy">
<span class="ss-plate__heading">A Supported Systems Joint</span>
<span class="ss-plate__body">
This clock is built and maintained by
<span class="ss-plate__name">Supported Systems</span> — a boutique
software studio focused on thoughtful, user-first technology. We
measure things before we believe them.
</span>
<span class="ss-plate__cta">
Visit supported.systems
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
<path d="M4 8h7M8 5l3 3-3 3" stroke-width="1.5" />
</svg>
</span>
</span>
</a>
</aside>
<div class="foot-meta">
<span id="foot-version">gpsntp-dashboard</span>
<span id="foot-updated"></span>
</div>
</footer>
<script src="/static/app.js"></script>
</body>
</html>

View file

@ -0,0 +1,225 @@
:root {
--bg: #0a0e13;
--bg-2: #0e141c;
--panel: #121a23;
--panel-2: #16212d;
--border: #21303f;
--border-soft: #1a2632;
--text: #e7eef5;
--muted: #8a99a8;
--faint: #57687a;
--accent: #22d3ee;
--good: #34d399;
--warn: #f5a524;
--bad: #f4436b;
/* constellations (no purple) */
--c-gps: #34d399;
--c-glonass: #38bdf8;
--c-galileo: #f5a524;
--c-beidou: #fb7185;
--c-sbas: #a3e635;
--c-qzss: #2dd4bf;
--c-other: #94a3b8;
--shadow: 0 1px 0 rgba(255, 255, 255, 0.03), 0 12px 30px -18px rgba(0, 0, 0, 0.9);
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(34, 211, 238, 0.06), transparent 60%),
radial-gradient(900px 500px at 0% 0%, rgba(52, 211, 153, 0.05), transparent 55%),
var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
svg { width: 1.15em; height: 1.15em; fill: none; stroke: currentColor; stroke-width: 2;
stroke-linecap: round; stroke-linejoin: round; }
/* ---------- top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 16px 20px;
border-bottom: 1px solid var(--border-soft);
position: sticky; top: 0; z-index: 5;
background: linear-gradient(var(--bg), rgba(10, 14, 19, 0.86));
backdrop-filter: blur(8px);
}
.brand { display: flex; align-items: center; gap: 13px; }
.brand-icon { width: 30px; height: 30px; color: var(--accent); }
.brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.3px; font-family: var(--mono); }
.brand-sub { margin: 0; color: var(--muted); font-size: 12.5px; }
.conn { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted);
padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--panel); }
.conn-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--faint); }
.conn[data-state="live"] .conn-dot { background: var(--good); box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.5);
animation: pulse 2s infinite; }
.conn[data-state="live"] { color: var(--good); }
.conn[data-state="down"] .conn-dot { background: var(--bad); }
.conn[data-state="down"] { color: var(--bad); }
@keyframes pulse { 70% { box-shadow: 0 0 0 7px rgba(52, 211, 153, 0); } 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0); } }
/* ---------- layout ---------- */
main { max-width: 1180px; margin: 0 auto; padding: 20px; display: grid; gap: 18px; }
.panel {
background: linear-gradient(var(--panel), var(--bg-2));
border: 1px solid var(--border-soft); border-radius: 16px; padding: 18px;
box-shadow: var(--shadow);
}
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.panel-head h2 { font-size: 14px; margin: 0; color: var(--muted); font-weight: 600;
display: flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: 0.6px; }
.panel-head h2 svg { color: var(--accent); }
/* ---------- hero ---------- */
.hero { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.clock { font-family: var(--mono); font-size: clamp(40px, 11vw, 76px); font-weight: 650;
letter-spacing: 1px; line-height: 1; font-variant-numeric: tabular-nums;
text-shadow: 0 0 34px rgba(34, 211, 238, 0.18); }
.clock-frac { color: var(--accent); font-size: 0.42em; }
.clock-meta { color: var(--muted); margin-top: 8px; font-size: 13.5px; }
.mono { font-family: var(--mono); }
.verdict { text-align: right; }
.stratum-badge { display: inline-flex; align-items: center; gap: 12px; padding: 12px 18px;
border-radius: 14px; border: 1px solid var(--border); background: var(--panel-2); }
.stratum-badge svg { width: 26px; height: 26px; color: var(--faint); }
.stratum-badge[data-ok="true"] { border-color: rgba(52, 211, 153, 0.5);
background: linear-gradient(rgba(52, 211, 153, 0.14), rgba(52, 211, 153, 0.04)); }
.stratum-badge[data-ok="true"] svg { color: var(--good); }
.stratum-num { font-size: 30px; font-weight: 750; font-family: var(--mono); display: block; line-height: 1; }
.stratum-badge[data-ok="true"] .stratum-num { color: var(--good); }
.stratum-word { font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: var(--muted); }
.ref-line { color: var(--muted); font-size: 13px; margin-top: 8px; }
.ref-line strong { color: var(--text); font-family: var(--mono); }
/* ---------- cards ---------- */
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--border-soft); border-radius: 14px; padding: 14px 15px; }
.card-head { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.4px; }
.card-head svg { width: 15px; height: 15px; color: var(--accent); }
.card-value { font-family: var(--mono); font-size: 25px; font-weight: 650; margin: 7px 0 3px; letter-spacing: 0.3px; }
.card-value.good { color: var(--good); }
.card-value.warn { color: var(--warn); }
.card-value.bad { color: var(--bad); }
.card-sub { color: var(--faint); font-size: 12px; }
.card-sub .ic-inline { width: 12px; height: 12px; vertical-align: -2px; }
/* ---------- sparkline ---------- */
.spark-meta { font: 12px var(--mono); color: var(--muted); }
.spark-big { width: 100%; height: 120px; display: block; }
.spark-zero { stroke: var(--border); stroke-width: 1; stroke-dasharray: 4 4; }
.spark-area { fill: url(#spark-grad); opacity: 0.9; }
.spark-line { fill: none; stroke: var(--accent); stroke-width: 1.6; vector-effect: non-scaling-stroke; }
/* ---------- grid-2 ---------- */
.grid-2 { display: grid; grid-template-columns: 1fr; gap: 18px; }
@media (min-width: 820px) { .grid-2 { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } }
/* ---------- sky ---------- */
.sky-hold { position: relative; }
#sky { width: 100%; height: auto; display: block; }
.sky-ring { fill: none; stroke: var(--border); stroke-width: 1; }
.sky-ring.faint { stroke: var(--border-soft); }
.sky-cross { stroke: var(--border-soft); stroke-width: 1; }
.sky-card-label { fill: var(--faint); font: 600 11px var(--mono); }
.sky-ring-label { fill: var(--faint); font: 10px var(--mono); }
.sat { cursor: pointer; transition: r 0.4s ease; }
.sat-halo { opacity: 0.18; }
.sat-label { fill: #05080c; font: 700 8px var(--mono); pointer-events: none; }
.legend { display: flex; flex-wrap: wrap; gap: 10px; }
.legend span { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--muted); }
.legend i { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
.sky-tip { position: absolute; pointer-events: none; background: #05090e; border: 1px solid var(--border);
border-radius: 8px; padding: 6px 9px; font: 12px/1.35 var(--mono); color: var(--text);
transform: translate(-50%, -120%); white-space: nowrap; z-index: 3; box-shadow: var(--shadow); }
/* ---------- snr bars ---------- */
.snr-panel { display: flex; flex-direction: column; }
.snr { display: flex; align-items: flex-end; gap: 5px; flex: 1; min-height: 190px; overflow-x: auto; padding-top: 6px; }
.snr-bar { flex: 0 0 auto; width: 18px; display: flex; flex-direction: column; align-items: center;
justify-content: flex-end; height: 100%; gap: 4px; }
.snr-fill { width: 100%; border-radius: 4px 4px 2px 2px; min-height: 3px; transition: height 0.5s ease; opacity: 0.55; }
.snr-bar.used .snr-fill { opacity: 1; }
.snr-prn { font: 9px var(--mono); color: var(--faint); }
.snr-empty { color: var(--faint); align-self: center; margin: auto; font-size: 13px; }
/* ---------- table ---------- */
.table-hold { overflow-x: auto; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; }
.tbl th { text-align: left; color: var(--faint); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 10px; border-bottom: 1px solid var(--border); }
.tbl td { padding: 9px 10px; border-bottom: 1px solid var(--border-soft); font-family: var(--mono); }
.tbl .num { text-align: right; }
.tbl tr.sel td { background: rgba(52, 211, 153, 0.06); }
.tbl tr.refclk td:first-child { color: var(--accent); }
.pill { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11px;
border: 1px solid var(--border); color: var(--muted); font-family: var(--mono); }
.pill.selected { color: var(--good); border-color: rgba(52, 211, 153, 0.45); background: rgba(52, 211, 153, 0.08); }
.pill.combined { color: var(--accent); border-color: rgba(34, 211, 238, 0.35); }
/* A noselect refclock (our GPS: labels the second, never chosen) is healthy,
not broken. Render it as quiet information so that red keeps meaning red. */
.pill.reference_only { color: var(--faint); border-color: var(--border-soft); }
.pill.unreachable, .pill.falseticker { color: var(--bad); border-color: rgba(244, 67, 107, 0.35); }
/* ---------- clients ---------- */
.clients { display: flex; flex-wrap: wrap; gap: 10px; }
.client { display: flex; flex-direction: column; gap: 2px; padding: 10px 13px; border: 1px solid var(--border-soft);
border-radius: 11px; background: var(--panel); min-width: 150px; }
.client .addr { font-family: var(--mono); font-size: 13px; }
.client .meta { color: var(--faint); font-size: 11.5px; }
.empty-note { color: var(--faint); font-size: 13px; }
/* ---------- footer ---------- */
.foot { max-width: 1180px; margin: 0 auto; padding: 14px 20px 30px; }
.foot-meta { display: flex; justify-content: space-between; margin-top: 14px;
color: var(--faint); font-size: 12px; font-family: var(--mono); }
/* ---------- the maker's plate ----------
* Clockmakers signed the backplate the brass face only a repairer sees once
* the case is open. This footer is that plate: a double hairline for the plate
* edge, engraved small-caps for the name, and nothing that moves. A signature
* should be quiet. Same markup as the docs site at cuckoo.warehack.ing,
* recolored from brass to this dashboard's cyan.
*/
.ss-plate__link {
display: flex; gap: 16px; align-items: center;
padding: 18px 20px; text-decoration: none; color: var(--muted);
border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 0 0 3px var(--bg), 0 0 0 4px var(--border-soft), var(--shadow);
background:
radial-gradient(120% 140% at 0% 0%, rgba(34, 211, 238, 0.07), transparent 60%),
var(--panel);
transition: color .2s, border-color .2s;
}
.ss-plate__link:hover { color: var(--text); border-color: var(--accent); }
.ss-plate__logo { flex: 0 0 auto; width: 46px; height: auto; opacity: .85;
transition: opacity .2s; }
.ss-plate__link:hover .ss-plate__logo { opacity: 1; }
.ss-plate__heading { display: block; margin-bottom: 5px; color: var(--text);
font-family: var(--mono); font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: .14em; }
.ss-plate__body { display: block; font-size: 13px; line-height: 1.55; max-width: 62ch; }
.ss-plate__name { color: var(--accent); }
.ss-plate__cta { display: inline-flex; align-items: center; gap: 5px; margin-top: 8px;
font-size: 12px; color: var(--accent); }
.ss-plate__cta svg { width: 14px; height: 14px; transition: transform .2s; }
.ss-plate__link:hover .ss-plate__cta svg { transform: translateX(2px); }
@media (max-width: 560px) {
.ss-plate__link { flex-direction: column; align-items: flex-start; }
}
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
/* "approx" marker when GPSNTP_POSITION=coarse */
.pos-note { color: var(--faint); font-size: 10px; text-transform: uppercase; letter-spacing: .5px; }

View file

@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 75" height="100%" width="100%">
<!-- Gradient Definitions -->
<defs>
<linearGradient id="gradient1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#60a5fa"></stop>
<stop offset="50%" stop-color="#3b82f6"></stop>
<stop offset="100%" stop-color="#2563eb"></stop>
</linearGradient>
<linearGradient id="gradient2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#93c5fd"></stop>
<stop offset="50%" stop-color="#60a5fa"></stop>
<stop offset="100%" stop-color="#3b82f6"></stop>
</linearGradient>
<linearGradient id="flowGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#2563eb"></stop>
<stop offset="50%" stop-color="#60a5fa"></stop>
<stop offset="100%" stop-color="#2563eb"></stop>
</linearGradient>
<pattern id="circuitPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
<rect width="12" height="45" fill="url(#gradient1)"></rect>
<path d="M2,5 h8 M2,5 v5 M10,5 v10 M5,15 h5 M5,15 v10 M3,25 h7 M7,25 v10 M3,35 h4" stroke="#dbeafe" stroke-width="0.5" fill="none" opacity="0.7"></path>
<circle cx="2" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="10" cy="5" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="5" cy="15" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="3" cy="25" r="1" fill="#dbeafe" opacity="0.7"></circle>
<circle cx="7" cy="35" r="1" fill="#dbeafe" opacity="0.7"></circle>
</pattern>
<pattern id="binaryPattern" patternUnits="userSpaceOnUse" width="12" height="35" patternTransform="scale(1)">
<rect width="12" height="35" fill="#2563eb"></rect>
<text x="3" y="8" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
<text x="3" y="14" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">01</text>
<text x="3" y="20" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">11</text>
<text x="3" y="26" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">00</text>
<text x="3" y="32" font-family="monospace" font-size="3" fill="#FFFFFF" opacity="0.5">10</text>
</pattern>
<pattern id="punchCardPattern" patternUnits="userSpaceOnUse" width="12" height="45" patternTransform="scale(1)">
<rect width="12" height="45" fill="#3b82f6"></rect>
<path d="M0,5 h12 M0,10 h12 M0,15 h12 M0,20 h12 M0,25 h12 M0,30 h12 M0,35 h12 M0,40 h12" stroke="#93c5fd" stroke-width="0.2" fill="none"></path>
<circle cx="3" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="7" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="6" cy="12" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="3" cy="17" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="22" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="6" cy="27" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="3" cy="32" r="1" fill="#1e3a8a" opacity="0.9"></circle>
<circle cx="9" cy="37" r="1" fill="#1e3a8a" opacity="0.9"></circle>
</pattern>
</defs>
<!-- Flow lines behind bars -->
<g opacity="0.3">
<path d="M6,50 C20,40 40,55 48,35 C56,50 75,30 90,55" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
<path d="M6,60 C30,50 50,40 70,55 C80,45 90,60 90,60" stroke="url(#flowGradient)" stroke-width="1" fill="none"></path>
</g>
<!-- Bar chart graphic - the "towers" -->
<g>
<rect x="0" y="45" width="12" height="25" rx="1" ry="1" fill="url(#binaryPattern)"></rect>
<rect x="14" y="35" width="12" height="35" rx="1" ry="1" fill="#2563eb"></rect>
<rect x="28" y="25" width="12" height="45" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
<rect x="42" y="20" width="12" height="50" rx="1" ry="1" fill="url(#gradient2)"></rect>
<rect x="56" y="25" width="12" height="45" rx="1" ry="1" fill="url(#punchCardPattern)"></rect>
<rect x="70" y="35" width="12" height="35" rx="1" ry="1" fill="url(#circuitPattern)"></rect>
<rect x="84" y="45" width="12" height="25" rx="1" ry="1" fill="#2563eb"></rect>
<!-- Connecting glow -->
<path d="M12,55 L14,55 M26,45 L28,45 M40,40 L42,40 M54,40 L56,40 M82,55 L84,55" stroke="#bfdbfe" stroke-width="0.8" stroke-opacity="0.6"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB