NTP Stratum Visualizer
let currentFormat = 'ntpq';
const EXAMPLES = {
ntpq: ` remote refid st t when poll reach delay offset jitter
==============================================================================
*216.239.35.8 .GPS. 1 u 150 256 377 0.789 -0.243 0.187
+162.159.200.1 216.239.35.0 2 u 55 256 377 5.032 0.112 0.543
+192.168.1.10 10.0.0.1 2 u 120 256 345 1.500 0.031 0.200
-192.168.1.11 10.0.0.1 2 u 90 256 255 2.100 0.500 1.200
10.0.0.99 .INIT. 16 u - 64 0 0.000 0.000 0.000`,
chrony: `MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
^* ntp1.example.com 1 6 377 23 +12us[ +13us] +/- 476us
^+ 192.168.1.1 2 6 377 23 -135us[-134us] +/- 856us
^- ntp2.example.net 2 7 377 128 +2345us[+2346us] +/- 999us
^? bad-server.example.com 0 8 0 10y +0ns[ +0ns] +/- 0ns`,
cisco: ` address ref clock st when poll reach delay offset disp
*~216.239.35.8 .GPS. 1 30 64 377 1.00 -0.500 0.700
~192.168.1.10 216.239.35.0 2 45 64 377 2.50 0.100 0.800
~10.0.0.1 .INIT. 16 - 64 0 0.000 0.000 16.00`
};
function setFormat(fmt) {
currentFormat = fmt;
document.querySelectorAll('.format-btn').forEach(b => b.classList.remove('active'));
document.getElementById('btn-' + fmt).classList.add('active');
document.getElementById('inputText').placeholder =
fmt === 'ntpq' ? 'Paste ntpq -p output here...' :
fmt === 'chrony' ? 'Paste chronyc sources output here...' :
'Paste "show ntp associations" output here...';
}
window.setFormat = setFormat;
function loadExample() {
document.getElementById('inputText').value = EXAMPLES[currentFormat];
}
window.loadExample = loadExample;
function clearAll() {
document.getElementById('inputText').value = '';
document.getElementById('resultsDiv').style.display = 'none';
}
window.clearAll = clearAll;
// ── Parsers ───────────────────────────────────────────────────────────────────
function parseNtpq(text) {
const servers = [];
for (const line of text.split('\n')) {
const m = line.match(/^([*+\-ox? ])(\S+)\s+(\S+)\s+(\d+)\s+(\w)\s+(\S+)\s+(\d+)\s+([0-7]+)\s+([\d.]+)\s+([-\d.]+)\s+([\d.]+)/);
if (!m) continue;
servers.push({
status: m[1].trim() || ' ',
remote: m[2],
refid: m[3],
stratum: parseInt(m[4], 10),
type: m[5],
when: m[6],
poll: parseInt(m[7], 10),
reach: parseInt(m[8], 8),
delay: parseFloat(m[9]),
offset: parseFloat(m[10]),
jitter: parseFloat(m[11]),
});
}
return servers;
}
function parseChrony(text) {
const servers = [];
for (const line of text.split('\n')) {
// chronyc format: mode char (^=NTP server, =peer, #=refclock) then status char (*+- x~#?)
const m = line.match(/^([*=#^]?)([*+\-x~#?])\s+(\S+)\s+(\d+)\s+(\d+)\s+([0-7]+)\s+(\S+)/);
if (!m) continue;
servers.push({
status: m[2],
remote: m[3],
refid: '',
stratum: parseInt(m[4], 10),
type: m[1] === '^' ? 'NTS' : 'u',
when: m[7],
poll: parseInt(m[5], 10),
reach: parseInt(m[6], 8),
delay: 0,
offset: 0,
jitter: 0,
});
}
return servers;
}
function parseCisco(text) {
const servers = [];
for (const line of text.split('\n')) {
const m = line.match(/^([* ])\s*[~]?\s*(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\d+)\s+([0-7]+)\s+([\d.]+)\s+([-\d.]+)/);
if (!m) continue;
servers.push({
status: m[1].trim() || ' ',
remote: m[2],
refid: m[3],
stratum: parseInt(m[4], 10),
type: 'u',
when: m[5],
poll: parseInt(m[6], 10),
reach: parseInt(m[7], 8),
delay: parseFloat(m[8]),
offset: parseFloat(m[9]),
jitter: 0,
});
}
return servers;
}
function parseInput(text, fmt) {
if (fmt === 'chrony') return parseChrony(text);
if (fmt === 'cisco') return parseCisco(text);
return parseNtpq(text);
}
// ── Status helpers ─────────────────────────────────────────────────────────
const STATUS_META = {
'*': { label: '*', cls: 's-synced', rowCls: 'synced', title: 'Current sync source' },
'o': { label: 'o', cls: 's-synced', rowCls: 'synced', title: 'PPS sync source' },
'+': { label: '+', cls: 's-candidate', rowCls: 'candidate', title: 'Selected candidate' },
'#': { label: '#', cls: 's-candidate', rowCls: 'candidate', title: 'Selected (excess)' },
'-': { label: '-', cls: 's-rejected', rowCls: 'rejected', title: 'Discarded (cluster algorithm)' },
'x': { label: 'x', cls: 's-rejected', rowCls: 'rejected', title: 'Rejected (falseticker)' },
'~': { label: '~', cls: 's-unknown', rowCls: 'unreachable', title: 'Too variable' },
'?': { label: '?', cls: 's-unknown', rowCls: 'unreachable', title: 'Unreachable' },
' ': { label: ' ', cls: 's-unknown', rowCls: 'unreachable', title: 'Not selected' },
};
function getMeta(s) {
return STATUS_META[s] || STATUS_META[' '];
}
function reachDots(reach) {
const bits = reach.toString(2).padStart(8, '0');
return bits.split('').map(b =>
``
).join('');
}
// ── Render table ───────────────────────────────────────────────────────────
function renderTable(servers) {
const tbody = document.getElementById('serverTableBody');
tbody.innerHTML = '';
for (const s of servers) {
const meta = getMeta(s.status);
const tr = document.createElement('tr');
tr.className = meta.rowCls;
tr.innerHTML = `
${s.remote}
${s.refid || '—'}
${s.stratum === 16 ? '16 (unsync)' : s.stratum}
${s.poll}
${s.delay.toFixed(3)}
${s.offset.toFixed(3)}
${s.jitter.toFixed(3)} `;
tbody.appendChild(tr);
}
}
// ── SVG stratum diagram ───────────────────────────────────────────────────
function renderDiagram(servers) {
const svg = document.getElementById('diagram');
svg.innerHTML = '';
if (servers.length === 0) return;
const W = 700, NODE_H = 40, PAD = 20, ROW_H = 60;
const validServers = servers.filter(s => s.stratum < 16);
if (validServers.length === 0) {
svg.setAttribute('width', W);
svg.setAttribute('height', 60);
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
t.setAttribute('x', W/2); t.setAttribute('y', 35);
t.setAttribute('text-anchor', 'middle'); t.setAttribute('fill', '#9ca3af');
t.setAttribute('font-size', '14');
t.textContent = 'No reachable servers to diagram.';
svg.appendChild(t);
return;
}
const refclocks = [...new Set(
validServers.map(s => s.refid).filter(r => r.startsWith('.') && r.endsWith('.'))
)];
const strata = [...new Set(validServers.map(s => s.stratum))].sort((a,b) => a-b);
const rows = refclocks.length > 0 ? ['refclk', ...strata] : strata;
const height = PAD + rows.length * ROW_H + ROW_H + PAD;
svg.setAttribute('width', W);
svg.setAttribute('height', height);
svg.style.width = '100%';
svg.style.maxWidth = W + 'px';
function rowY(rowIdx) { return PAD + rowIdx * ROW_H + ROW_H / 2; }
const ns = 'http://www.w3.org/2000/svg';
function el(tag, attrs, text) {
const e = document.createElementNS(ns, tag);
for (const [k,v] of Object.entries(attrs)) e.setAttribute(k, v);
if (text) e.textContent = text;
return e;
}
rows.forEach((row, i) => {
const y = rowY(i);
svg.appendChild(el('text', { x: 8, y: y + 5, 'font-size': '12', fill: '#6b7280' },
row === 'refclk' ? 'Stratum 0 (Ref Clock)' : `Stratum ${row}`));
svg.appendChild(el('line', { x1: 0, y1: y + ROW_H/2, x2: W, y2: y + ROW_H/2,
stroke: '#e5e7eb', 'stroke-width': '1' }));
});
const thisY = rowY(rows.length);
svg.appendChild(el('text', { x: 8, y: thisY + 5, 'font-size': '12', fill: '#6b7280' },
'This Host'));
const stratumNodes = {};
strata.forEach(st => {
const inStratum = validServers.filter(s => s.stratum === st);
const rowIdx = rows.indexOf(st);
const totalW = W - 120;
const spacing = inStratum.length > 1 ? totalW / (inStratum.length - 1) : 0;
inStratum.forEach((s, i) => {
const x = 120 + (inStratum.length === 1 ? totalW / 2 : spacing * i);
const y = rowY(rowIdx);
stratumNodes[s.remote] = { x, y, server: s };
});
});
if (refclocks.length > 0) {
const rowIdx = rows.indexOf('refclk');
const totalW = W - 120;
refclocks.forEach((rc, i) => {
const x = 120 + (refclocks.length === 1 ? totalW / 2 : (totalW / (refclocks.length - 1)) * i);
const y = rowY(rowIdx);
stratumNodes['rc:' + rc] = { x, y, isRefclk: true, label: rc };
});
}
for (const { x, y, server, isRefclk } of Object.values(stratumNodes)) {
if (isRefclk) continue;
const refKey = 'rc:' + server.refid;
if (stratumNodes[refKey]) {
const { x: rx, y: ry } = stratumNodes[refKey];
svg.appendChild(el('line', { x1: rx, y1: ry, x2: x, y2: y,
stroke: '#d1d5db', 'stroke-width': '1.5', 'stroke-dasharray': '4,3' }));
}
if (server.status === '*' || server.status === 'o') {
svg.appendChild(el('line', { x1: x, y1: y, x2: W/2, y2: thisY,
stroke: '#22c55e', 'stroke-width': '2' }));
} else if (server.status === '+' || server.status === '#') {
svg.appendChild(el('line', { x1: x, y1: y, x2: W/2, y2: thisY,
stroke: '#d1d5db', 'stroke-width': '1', 'stroke-dasharray': '4,3' }));
}
}
for (const { x, y, server, isRefclk, label } of Object.values(stratumNodes)) {
const meta = isRefclk ? null : getMeta(server ? server.status : ' ');
const fill = isRefclk ? '#8b5cf6' :
meta.rowCls === 'synced' ? '#22c55e' :
meta.rowCls === 'candidate' ? '#eab308' :
meta.rowCls === 'rejected' ? '#ef4444' : '#9ca3af';
svg.appendChild(el('circle', { cx: x, cy: y, r: 16, fill, opacity: '0.9' }));
const lbl = isRefclk ? label : (server.remote.length > 14 ? server.remote.slice(0,13)+'…' : server.remote);
svg.appendChild(el('text', { x, y: y + 28, 'text-anchor': 'middle',
'font-size': '10', fill: '#374151' }, lbl));
if (!isRefclk && server) {
svg.appendChild(el('text', { x, y: y + 5, 'text-anchor': 'middle',
'font-size': '10', fill: 'white', 'font-weight': 'bold' },
server.status === ' ' ? '?' : server.status));
}
}
svg.appendChild(el('rect', { x: W/2 - 40, y: thisY - 16, width: 80, height: 32,
rx: 8, fill: '#3b82f6', opacity: '0.9' }));
svg.appendChild(el('text', { x: W/2, y: thisY + 5, 'text-anchor': 'middle',
'font-size': '11', fill: 'white', 'font-weight': 'bold' }, 'This Host'));
}
// ── Summary ───────────────────────────────────────────────────────────────
function renderSummary(servers) {
const div = document.getElementById('summaryDiv');
const synced = servers.find(s => s.status === '*' || s.status === 'o');
const candidates = servers.filter(s => s.status === '+' || s.status === '#').length;
const unreachable = servers.filter(s => s.reach === 0 || s.stratum === 16).length;
let html = '
${reachDots(s.reach)}
- ';
if (synced) {
html += `
- Sync source: ${synced.remote} (Stratum ${synced.stratum}`; if (synced.refid) html += `, RefID: ${synced.refid}`; html += `)`; if (synced.offset !== undefined) html += ` — Offset: ${synced.offset.toFixed(3)} ms, Jitter: ${synced.jitter.toFixed(3)} ms`; html += ' '; } else { html += `
- No sync source found. The host is not synchronized. `; } html += `
- Candidate servers: ${candidates} `; html += `
- Unreachable / unsynchronized: ${unreachable} `; if (synced && Math.abs(synced.offset) > 128) html += `
- Warning: Offset ${synced.offset.toFixed(1)} ms exceeds 128 ms — NTP may step-adjust or fail. `; html += '
References: RFC 5905 (NTPv4) • RFC 8915 (NTS) • chrony docs
`; div.innerHTML = html; } // ── Main ────────────────────────────────────────────────────────────────── function visualize() { const text = document.getElementById('inputText').value.trim(); if (!text) return; const servers = parseInput(text, currentFormat); if (servers.length === 0) { alert('No server rows recognized. Check that the input format matches the selected format button.'); return; } renderTable(servers); renderDiagram(servers); renderSummary(servers); document.getElementById('resultsDiv').style.display = 'block'; } window.visualize = visualize; })();