<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>rusty_time gateway</title>
<style>
:root {
--paper: #f5f7f9; --ink: #1a222c; --ink-2: #4a5866; --line: #d4dce2;
--accent: #0c7489; --good: #2e7d4f; --warn: #9a6a1b;
}
@media (prefers-color-scheme: dark) {
:root {
--paper: #0f141b; --ink: #dce3ea; --ink-2: #9aa8b4; --line: #2a3542;
--accent: #3fa9be; --good: #5cb884; --warn: #d2a24c;
}
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: 2rem 1.25rem 4rem;
}
main { max-width: 44rem; margin: 0 auto; }
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -.01em; }
.sub { color: var(--ink-2); margin: 0 0 2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); gap: 1rem; }
.tile {
border: 1px solid var(--line); border-radius: 8px; padding: 1rem;
background: color-mix(in srgb, var(--paper) 92%, var(--ink) 8%);
}
.label {
font: 600 .68rem/1.4 ui-monospace, monospace; letter-spacing: .12em;
text-transform: uppercase; color: var(--ink-2); margin-bottom: .4rem;
}
.value { font: 500 1.5rem/1.2 ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.value.small { font-size: 1.05rem; }
.ok { color: var(--good); } .pending { color: var(--warn); }
table { border-collapse: collapse; width: 100%; margin-top: 2rem; font-size: .86rem; }
th, td {
text-align: right; padding: .4rem .6rem; border-bottom: 1px solid var(--line);
font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace;
}
th { color: var(--ink-2); font-weight: 600; font-size: .7rem; text-transform: uppercase; }
th:first-child, td:first-child { text-align: left; }
.note { color: var(--ink-2); font-size: .85rem; margin-top: 2rem; }
code { background: color-mix(in srgb, var(--paper) 85%, var(--ink) 15%); padding: .1em .35em; border-radius: 3px; }
</style>
</head>
<body>
<main>
<h1>rusty_time gateway</h1>
<p class="sub">A browser has no UDP socket and no clock it may set. This page
measures the offset of <em>this machine's</em> clock against the gateway and
corrects for it in software, using the same NTP codec the daemon uses.</p>
<div class="grid">
<div class="tile">
<div class="label">Corrected time</div>
<div class="value small" id="corrected">—</div>
</div>
<div class="tile">
<div class="label">Offset</div>
<div class="value" id="offset">—</div>
</div>
<div class="tile">
<div class="label">Error bound</div>
<div class="value" id="confidence">—</div>
</div>
<div class="tile">
<div class="label">Exchanges</div>
<div class="value" id="exchanges">0</div>
</div>
</div>
<table>
<thead><tr><th>#</th><th>offset (ms)</th><th>round trip (ms)</th></tr></thead>
<tbody id="samples"></tbody>
</table>
<p class="note" id="status">Loading the wasm module…</p>
<p class="note">The offset is what would be added to <code>Date.now()</code> to
match the gateway. The error bound widens between exchanges, because an
estimate that is not refreshed gets worse and should say so.</p>
</main>
<script type="module">
const els = {
corrected: document.getElementById('corrected'),
offset: document.getElementById('offset'),
confidence: document.getElementById('confidence'),
exchanges: document.getElementById('exchanges'),
samples: document.getElementById('samples'),
status: document.getElementById('status'),
};
const fmt = (ms, digits = 3) =>
(ms >= 0 ? '+' : '') + ms.toFixed(digits) + ' ms';
let client = null;
try {
const wasm = await import('./rusty_time_wasm.js');
await wasm.default();
client = new wasm.TimeClient();
els.status.textContent = 'Measuring…';
} catch (e) {
els.status.textContent =
'The wasm module is not being served from this origin, so this page can ' +
'only show the gateway is reachable. Build it with: wasm-pack build ' +
'crates/rusty_time-wasm --target web';
}
let count = 0;
async function exchange() {
if (!client) return;
const rnd = new Uint32Array(2);
crypto.getRandomValues(rnd);
const request = client.build_request(Date.now(), rnd[0], rnd[1]);
let response;
try {
const res = await fetch('/time', {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body: request,
cache: 'no-store',
});
if (!res.ok) { els.status.textContent = 'Gateway said ' + res.status; return; }
response = new Uint8Array(await res.arrayBuffer());
} catch (e) {
els.status.textContent = 'Gateway unreachable: ' + e;
return;
}
const t4 = Date.now();
const perf = performance.now();
const accepted = client.process_response(response, t4, perf);
if (!accepted) {
els.status.textContent = 'Reply rejected (it did not match our request).';
return;
}
count += 1;
const offset = client.offset_ms(perf);
els.offset.textContent = fmt(offset);
els.offset.className = 'value ok';
els.confidence.textContent = '±' + client.confidence_ms(perf).toFixed(2) + ' ms';
els.exchanges.textContent = String(count);
els.status.textContent = 'Synchronised with the gateway.';
const row = els.samples.insertRow(0);
row.insertCell().textContent = count;
row.insertCell().textContent = offset.toFixed(3);
row.insertCell().textContent = (performance.now() - perf + 0).toFixed(3);
while (els.samples.rows.length > 8) els.samples.deleteRow(8);
}
function tick() {
if (!client) return;
const perf = performance.now();
const corrected = client.now_ms(Date.now(), perf);
els.corrected.textContent = new Date(corrected).toISOString().replace('T', ' ').replace('Z', '');
els.confidence.textContent = '±' + client.confidence_ms(perf).toFixed(2) + ' ms';
}
await exchange();
setInterval(exchange, 8000);
setInterval(tick, 100);
</script>
</body>
</html>