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:
commit
6881489bf6
56 changed files with 10297 additions and 0 deletions
13
dashboard/.env.example
Normal file
13
dashboard/.env.example
Normal 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
52
dashboard/Makefile
Normal 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
|
||||
11
dashboard/deploy/Caddyfile.tmpl
Normal file
11
dashboard/deploy/Caddyfile.tmpl
Normal 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
|
||||
}
|
||||
10
dashboard/deploy/caddy-affinity.conf
Normal file
10
dashboard/deploy/caddy-affinity.conf
Normal 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
|
||||
37
dashboard/deploy/deploy.sh
Normal file
37
dashboard/deploy/deploy.sh
Normal 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"
|
||||
35
dashboard/deploy/gpsntp-dashboard.service
Normal file
35
dashboard/deploy/gpsntp-dashboard.service
Normal 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
|
||||
4
dashboard/deploy/gpsntp-dashboard.sudoers
Normal file
4
dashboard/deploy/gpsntp-dashboard.sudoers
Normal 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
29
dashboard/pyproject.toml
Normal 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"]
|
||||
3
dashboard/src/gpsntp_dashboard/__init__.py
Normal file
3
dashboard/src/gpsntp_dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Live status dashboard for a GPS/PPS-disciplined chrony Stratum 1 time server."""
|
||||
|
||||
__version__ = "2026.07.12"
|
||||
144
dashboard/src/gpsntp_dashboard/chrony.py
Normal file
144
dashboard/src/gpsntp_dashboard/chrony.py
Normal 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 []
|
||||
165
dashboard/src/gpsntp_dashboard/gpsd_reader.py
Normal file
165
dashboard/src/gpsntp_dashboard/gpsd_reader.py
Normal 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,
|
||||
}
|
||||
158
dashboard/src/gpsntp_dashboard/main.py
Normal file
158
dashboard/src/gpsntp_dashboard/main.py
Normal 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",
|
||||
)
|
||||
88
dashboard/src/gpsntp_dashboard/metrics.py
Normal file
88
dashboard/src/gpsntp_dashboard/metrics.py
Normal 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"
|
||||
295
dashboard/src/gpsntp_dashboard/static/app.js
Normal file
295
dashboard/src/gpsntp_dashboard/static/app.js
Normal 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();
|
||||
180
dashboard/src/gpsntp_dashboard/static/index.html
Normal file
180
dashboard/src/gpsntp_dashboard/static/index.html
Normal 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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><text y='18' font-size='18'>🛰️</text></svg>" />
|
||||
</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>
|
||||
225
dashboard/src/gpsntp_dashboard/static/style.css
Normal file
225
dashboard/src/gpsntp_dashboard/static/style.css
Normal 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; }
|
||||
|
|
@ -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 |
Loading…
Add table
Add a link
Reference in a new issue